loading
Generated 2026-02-04T18:21:45+00:00

All Files ( 3.96% covered at 0.38 hits/line )

525 files in total.
36859 relevant lines, 1460 lines covered and 35399 lines missed. ( 3.96% )
1780 total branches, 100 branches covered and 1680 branches missed. ( 5.62% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/admin/actions/user_grant_billing_admin_access_action.rb 0.00 % 12 10 0 10 0.00 100.00 % 0 0 0
app/admin/actions/user_revoke_billing_admin_access_action.rb 0.00 % 14 10 0 10 0.00 100.00 % 0 0 0
app/admin/base/export_builder.rb 0.00 % 176 116 0 116 0.00 100.00 % 0 0 0
app/admin/base/field_registry.rb 0.00 % 143 84 0 84 0.00 100.00 % 0 0 0
app/admin/base/navigation.rb 0.00 % 163 127 0 127 0.00 100.00 % 0 0 0
app/admin/base/portal.rb 0.00 % 143 60 0 60 0.00 100.00 % 0 0 0
app/admin/base/stats_calculator.rb 0.00 % 100 65 0 65 0.00 100.00 % 0 0 0
app/admin/portals/ai_portal.rb 0.00 % 43 30 0 30 0.00 100.00 % 0 0 0
app/admin/portals/ops_portal.rb 0.00 % 55 39 0 39 0.00 100.00 % 0 0 0
app/admin/resources/assistant_event_resource.rb 0.00 % 69 57 0 57 0.00 100.00 % 0 0 0
app/admin/resources/assistant_memory_proposal_resource.rb 0.00 % 70 58 0 58 0.00 100.00 % 0 0 0
app/admin/resources/assistant_thread_resource.rb 0.00 % 80 67 0 67 0.00 100.00 % 0 0 0
app/admin/resources/assistant_thread_summary_resource.rb 0.00 % 53 41 0 41 0.00 100.00 % 0 0 0
app/admin/resources/assistant_tool_execution_resource.rb 0.00 % 97 83 0 83 0.00 100.00 % 0 0 0
app/admin/resources/assistant_tool_resource.rb 0.00 % 107 90 0 90 0.00 100.00 % 0 0 0
app/admin/resources/assistant_turn_resource.rb 0.00 % 69 57 0 57 0.00 100.00 % 0 0 0
app/admin/resources/assistant_user_memory_resource.rb 0.00 % 71 58 0 58 0.00 100.00 % 0 0 0
app/admin/resources/billing_feature_resource.rb 0.00 % 43 35 0 35 0.00 100.00 % 0 0 0
app/admin/resources/billing_plan_entitlement_resource.rb 0.00 % 40 34 0 34 0.00 100.00 % 0 0 0
app/admin/resources/billing_plan_resource.rb 0.00 % 99 86 0 86 0.00 100.00 % 0 0 0
app/admin/resources/billing_provider_mapping_resource.rb 0.00 % 44 36 0 36 0.00 100.00 % 0 0 0
app/admin/resources/billing_subscription_resource.rb 0.00 % 40 33 0 33 0.00 100.00 % 0 0 0
app/admin/resources/billing_webhook_event_resource.rb 0.00 % 60 51 0 51 0.00 100.00 % 0 0 0
app/admin/resources/blog_post_resource.rb 0.00 % 102 85 0 85 0.00 100.00 % 0 0 0
app/admin/resources/category_resource.rb 0.00 % 60 45 0 45 0.00 100.00 % 0 0 0
app/admin/resources/company_feedback_resource.rb 0.00 % 44 33 0 33 0.00 100.00 % 0 0 0
app/admin/resources/company_resource.rb 0.00 % 81 67 0 67 0.00 100.00 % 0 0 0
app/admin/resources/connected_account_resource.rb 0.00 % 85 72 0 72 0.00 100.00 % 0 0 0
app/admin/resources/email_pipeline_event_resource.rb 0.00 % 70 61 0 61 0.00 100.00 % 0 0 0
app/admin/resources/email_pipeline_run_resource.rb 0.00 % 66 57 0 57 0.00 100.00 % 0 0 0
app/admin/resources/email_sender_resource.rb 0.00 % 99 84 0 84 0.00 100.00 % 0 0 0
app/admin/resources/html_scraping_log_resource.rb 0.00 % 88 76 0 76 0.00 100.00 % 0 0 0
app/admin/resources/interview_application_resource.rb 0.00 % 95 83 0 83 0.00 100.00 % 0 0 0
app/admin/resources/interview_round_resource.rb 0.00 % 60 49 0 49 0.00 100.00 % 0 0 0
app/admin/resources/interview_round_type_resource.rb 0.00 % 74 59 0 59 0.00 100.00 % 0 0 0
app/admin/resources/job_listing_resource.rb 0.00 % 126 109 0 109 0.00 100.00 % 0 0 0
app/admin/resources/job_role_resource.rb 0.00 % 65 52 0 52 0.00 100.00 % 0 0 0
app/admin/resources/llm_api_log_resource.rb 0.00 % 87 75 0 75 0.00 100.00 % 0 0 0
app/admin/resources/llm_prompt_resource.rb 0.00 % 102 86 0 86 0.00 100.00 % 0 0 0
app/admin/resources/llm_provider_config_resource.rb 0.00 % 107 88 0 88 0.00 100.00 % 0 0 0
app/admin/resources/scraping_attempt_resource.rb 0.00 % 109 96 0 96 0.00 100.00 % 0 0 0
app/admin/resources/scraping_event_resource.rb 0.00 % 72 60 0 60 0.00 100.00 % 0 0 0
app/admin/resources/setting_resource.rb 0.00 % 67 53 0 53 0.00 100.00 % 0 0 0
app/admin/resources/skill_tag_resource.rb 0.00 % 60 46 0 46 0.00 100.00 % 0 0 0
app/admin/resources/support_ticket_resource.rb 0.00 % 90 75 0 75 0.00 100.00 % 0 0 0
app/admin/resources/synced_email_resource.rb 0.00 % 146 128 0 128 0.00 100.00 % 0 0 0
app/admin/resources/user_resource.rb 0.00 % 111 98 0 98 0.00 100.00 % 0 0 0
app/admin_suite/portals/ai.rb 41.07 % 99 56 23 33 0.41 0.00 % 10 0 10
app/admin_suite/portals/assistant.rb 43.55 % 116 62 27 35 0.44 10.00 % 10 1 9
app/admin_suite/portals/email.rb 45.65 % 81 46 21 25 0.46 0.00 % 10 0 10
app/admin_suite/portals/ops.rb 42.62 % 104 61 26 35 0.43 0.00 % 8 0 8
app/admin_suite/portals/payments.rb 80.00 % 31 20 16 4 0.80 100.00 % 0 0 0
app/channels/application_cable/connection.rb 0.00 % 101 64 0 64 0.00 100.00 % 0 0 0
app/constraints/developer_authenticated_constraint.rb 40.00 % 24 5 2 3 0.40 0.00 % 2 0 2
app/controllers/ai_assistant/queries_controller.rb 0.00 % 72 57 0 57 0.00 100.00 % 0 0 0
app/controllers/ai_assistant/tool_executions_controller.rb 0.00 % 61 45 0 45 0.00 100.00 % 0 0 0
app/controllers/api/v1/base_controller.rb 0.00 % 22 9 0 9 0.00 100.00 % 0 0 0
app/controllers/api/v1/companies_controller.rb 0.00 % 53 33 0 33 0.00 100.00 % 0 0 0
app/controllers/api/v1/departments_controller.rb 0.00 % 27 16 0 16 0.00 100.00 % 0 0 0
app/controllers/api/v1/domains_controller.rb 0.00 % 53 33 0 33 0.00 100.00 % 0 0 0
app/controllers/api/v1/job_roles_controller.rb 0.00 % 63 40 0 40 0.00 100.00 % 0 0 0
app/controllers/application_controller.rb 71.43 % 27 14 10 4 0.71 0.00 % 2 0 2
app/controllers/archived_jobs_controller.rb 0.00 % 13 9 0 9 0.00 100.00 % 0 0 0
app/controllers/assistant/messages_controller.rb 0.00 % 121 75 0 75 0.00 100.00 % 0 0 0
app/controllers/assistant/threads_controller.rb 0.00 % 22 17 0 17 0.00 100.00 % 0 0 0
app/controllers/assistant/tool_executions_controller.rb 0.00 % 279 232 0 232 0.00 100.00 % 0 0 0
app/controllers/assistant/widgets_controller.rb 0.00 % 66 46 0 46 0.00 100.00 % 0 0 0
app/controllers/billing/checkouts_controller.rb 0.00 % 42 28 0 28 0.00 100.00 % 0 0 0
app/controllers/billing/portal_controller.rb 0.00 % 28 19 0 19 0.00 100.00 % 0 0 0
app/controllers/billing/returns_controller.rb 0.00 % 20 12 0 12 0.00 100.00 % 0 0 0
app/controllers/billing/subscriptions_controller.rb 0.00 % 69 50 0 50 0.00 100.00 % 0 0 0
app/controllers/categories_controller.rb 0.00 % 65 46 0 46 0.00 100.00 % 0 0 0
app/controllers/companies_controller.rb 0.00 % 83 58 0 58 0.00 100.00 % 0 0 0
app/controllers/company_feedbacks_controller.rb 0.00 % 80 58 0 58 0.00 100.00 % 0 0 0
app/controllers/concerns/authentication.rb 38.10 % 125 63 24 39 0.86 7.14 % 42 3 39
app/controllers/concerns/paginatable.rb 100.00 % 24 4 4 0 1.00 100.00 % 0 0 0
app/controllers/dashboard_controller.rb 0.00 % 190 136 0 136 0.00 100.00 % 0 0 0
app/controllers/email_verifications_controller.rb 0.00 % 46 32 0 32 0.00 100.00 % 0 0 0
app/controllers/inbox_controller.rb 0.00 % 358 243 0 243 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/base_controller.rb 0.00 % 23 16 0 16 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/dashboard_controller.rb 0.00 % 104 83 0 83 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/llm_api_logs_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/llm_prompts_controller.rb 0.00 % 46 35 0 35 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/llm_provider_configs_controller.rb 0.00 % 48 36 0 36 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/base_controller.rb 0.00 % 26 18 0 18 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/dashboard_controller.rb 0.00 % 104 83 0 83 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/events_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/memory_proposals_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/thread_summaries_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/threads_controller.rb 0.00 % 27 20 0 20 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/tool_executions_controller.rb 0.00 % 82 65 0 65 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/tools_controller.rb 0.00 % 48 36 0 36 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/turns_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/user_memories_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/base_controller.rb 0.00 % 182 108 0 108 0.00 100.00 % 0 0 0
app/controllers/internal/developer/dashboard_controller.rb 0.00 % 195 143 0 143 0.00 100.00 % 0 0 0
app/controllers/internal/developer/docs_controller.rb 0.00 % 163 127 0 127 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/base_controller.rb 0.00 % 22 16 0 16 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/dashboard_controller.rb 0.00 % 70 58 0 58 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/email_pipeline_events_controller.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/email_pipeline_runs_controller.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/synced_emails_controller.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/base_controller.rb 0.00 % 23 16 0 16 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/blog_posts_controller.rb 0.00 % 43 33 0 33 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/categories_controller.rb 0.00 % 50 38 0 38 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/companies_controller.rb 0.00 % 50 38 0 38 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/company_feedbacks_controller.rb 0.00 % 21 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/connected_accounts_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/dashboard_controller.rb 0.00 % 111 88 0 88 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/email_senders_controller.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/html_scraping_logs_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/interview_applications_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/interview_round_types_controller.rb 0.00 % 45 32 0 32 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/interview_rounds_controller.rb 0.00 % 21 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/job_listings_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/job_roles_controller.rb 0.00 % 50 38 0 38 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/scraping_attempts_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/scraping_events_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/settings_controller.rb 0.00 % 37 28 0 28 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/skill_tags_controller.rb 0.00 % 49 38 0 38 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/support_tickets_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/synced_emails_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/users_controller.rb 0.00 % 63 46 0 46 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/dashboard_controller.rb 0.00 % 32 23 0 23 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/features_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/plan_entitlements_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/plans_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/provider_mappings_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/subscriptions_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/webhook_events_controller.rb 0.00 % 29 20 0 20 0.00 100.00 % 0 0 0
app/controllers/internal/developer/resources_controller.rb 0.00 % 348 218 0 218 0.00 100.00 % 0 0 0
app/controllers/internal/developer/sessions_controller.rb 0.00 % 126 73 0 73 0.00 100.00 % 0 0 0
app/controllers/interview_application_preps_controller.rb 0.00 % 38 29 0 29 0.00 100.00 % 0 0 0
app/controllers/interview_applications_controller.rb 0.00 % 463 364 0 364 0.00 100.00 % 0 0 0
app/controllers/interview_feedbacks_controller.rb 0.00 % 94 70 0 70 0.00 100.00 % 0 0 0
app/controllers/interview_round_preps_controller.rb 0.00 % 84 60 0 60 0.00 100.00 % 0 0 0
app/controllers/interview_rounds_controller.rb 0.00 % 115 83 0 83 0.00 100.00 % 0 0 0
app/controllers/job_listings_controller.rb 0.00 % 148 116 0 116 0.00 100.00 % 0 0 0
app/controllers/job_roles_controller.rb 0.00 % 87 61 0 61 0.00 100.00 % 0 0 0
app/controllers/oauth_callbacks_controller.rb 0.00 % 98 60 0 60 0.00 100.00 % 0 0 0
app/controllers/opportunities_controller.rb 0.00 % 262 204 0 204 0.00 100.00 % 0 0 0
app/controllers/passwords_controller.rb 0.00 % 36 30 0 30 0.00 100.00 % 0 0 0
app/controllers/profiles_controller.rb 0.00 % 52 40 0 40 0.00 100.00 % 0 0 0
app/controllers/public/base_controller.rb 0.00 % 55 28 0 28 0.00 100.00 % 0 0 0
app/controllers/public/blog_controller.rb 0.00 % 93 79 0 79 0.00 100.00 % 0 0 0
app/controllers/public/blog_tags_controller.rb 0.00 % 41 30 0 30 0.00 100.00 % 0 0 0
app/controllers/public/contacts_controller.rb 0.00 % 59 32 0 32 0.00 100.00 % 0 0 0
app/controllers/public/home_controller.rb 0.00 % 19 6 0 6 0.00 100.00 % 0 0 0
app/controllers/public/legal_controller.rb 0.00 % 26 14 0 14 0.00 100.00 % 0 0 0
app/controllers/public/newsletter_subscriptions_controller.rb 0.00 % 35 24 0 24 0.00 100.00 % 0 0 0
app/controllers/public/pricing_controller.rb 0.00 % 13 7 0 7 0.00 100.00 % 0 0 0
app/controllers/public/sitemaps_controller.rb 0.00 % 18 14 0 14 0.00 100.00 % 0 0 0
app/controllers/registrations_controller.rb 0.00 % 52 34 0 34 0.00 100.00 % 0 0 0
app/controllers/resume_skills_controller.rb 0.00 % 176 128 0 128 0.00 100.00 % 0 0 0
app/controllers/saved_jobs_controller.rb 0.00 % 104 78 0 78 0.00 100.00 % 0 0 0
app/controllers/sessions_controller.rb 0.00 % 33 29 0 29 0.00 100.00 % 0 0 0
app/controllers/settings_controller.rb 0.00 % 729 502 0 502 0.00 100.00 % 0 0 0
app/controllers/signals_controller.rb 0.00 % 627 450 0 450 0.00 100.00 % 0 0 0
app/controllers/skill_tags_controller.rb 0.00 % 59 41 0 41 0.00 100.00 % 0 0 0
app/controllers/skills_controller.rb 0.00 % 182 133 0 133 0.00 100.00 % 0 0 0
app/controllers/user_resumes_controller.rb 0.00 % 290 208 0 208 0.00 100.00 % 0 0 0
app/controllers/webhooks/lemon_squeezy_controller.rb 0.00 % 70 51 0 51 0.00 100.00 % 0 0 0
app/domains/assistant.rb 100.00 % 8 1 1 0 1.00 100.00 % 0 0 0
app/domains/assistant/contracts/provider_result_contracts.rb 0.00 % 32 24 0 24 0.00 100.00 % 0 0 0
app/domains/assistant/contracts/tool_call_contract.rb 0.00 % 29 16 0 16 0.00 100.00 % 0 0 0
app/domains/assistant/contracts/tool_result_contract.rb 0.00 % 24 17 0 17 0.00 100.00 % 0 0 0
app/domains/assistant/models/chat_message.rb 0.00 % 22 14 0 14 0.00 100.00 % 0 0 0
app/domains/assistant/models/chat_thread.rb 66.67 % 60 18 12 6 0.67 0.00 % 6 0 6
app/domains/assistant/models/has_uuid.rb 83.33 % 29 12 10 2 1.00 100.00 % 0 0 0
app/domains/assistant/models/memory/memory_proposal.rb 0.00 % 20 14 0 14 0.00 100.00 % 0 0 0
app/domains/assistant/models/memory/thread_summary.rb 0.00 % 15 11 0 11 0.00 100.00 % 0 0 0
app/domains/assistant/models/memory/user_memory.rb 0.00 % 15 10 0 10 0.00 100.00 % 0 0 0
app/domains/assistant/models/ops/event.rb 0.00 % 17 11 0 11 0.00 100.00 % 0 0 0
app/domains/assistant/models/tool.rb 0.00 % 19 13 0 13 0.00 100.00 % 0 0 0
app/domains/assistant/models/tool_execution.rb 100.00 % 31 13 13 0 1.00 100.00 % 0 0 0
app/domains/assistant/models/turn.rb 0.00 % 35 24 0 24 0.00 100.00 % 0 0 0
app/domains/assistant/policies/tool_policy.rb 0.00 % 23 14 0 14 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/components/llm_responder.rb 0.00 % 362 279 0 279 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/components/prompt_builder.rb 0.00 % 71 52 0 52 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/components/tool_followup_responder.rb 0.00 % 280 218 0 218 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/components/tool_proposal_recorder.rb 0.00 % 54 44 0 44 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/orchestrator.rb 0.00 % 68 42 0 42 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/tool_result_message_persister.rb 0.00 % 63 46 0 46 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/turn_runner.rb 0.00 % 166 120 0 120 0.00 100.00 % 0 0 0
app/domains/assistant/services/context/builder.rb 0.00 % 234 178 0 178 0.00 100.00 % 0 0 0
app/domains/assistant/services/memory/memory_proposer.rb 0.00 % 113 89 0 89 0.00 100.00 % 0 0 0
app/domains/assistant/services/memory/thread_summarizer.rb 0.00 % 96 71 0 71 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/anthropic/message_builder.rb 0.00 % 145 113 0 113 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/anthropic/parser.rb 0.00 % 22 17 0 17 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/openai/message_builder.rb 0.00 % 86 68 0 68 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/openai/parser.rb 0.00 % 21 16 0 16 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/provider_router.rb 0.00 % 93 71 0 71 0.00 100.00 % 0 0 0
app/domains/assistant/services/tools/arg_schema_validator.rb 0.00 % 75 52 0 52 0.00 100.00 % 0 0 0
app/domains/assistant/services/tools/runner.rb 0.00 % 120 87 0 87 0.00 100.00 % 0 0 0
app/domains/assistant/services/tools/tool_schema_adapter.rb 0.00 % 120 81 0 81 0.00 100.00 % 0 0 0
app/domains/assistant/tools/add_note_to_application_tool.rb 0.00 % 42 34 0 34 0.00 100.00 % 0 0 0
app/domains/assistant/tools/add_target_company_tool.rb 0.00 % 92 70 0 70 0.00 100.00 % 0 0 0
app/domains/assistant/tools/add_target_domain_tool.rb 0.00 % 92 70 0 70 0.00 100.00 % 0 0 0
app/domains/assistant/tools/add_target_job_role_tool.rb 0.00 % 92 70 0 70 0.00 100.00 % 0 0 0
app/domains/assistant/tools/base_tool.rb 0.00 % 15 11 0 11 0.00 100.00 % 0 0 0
app/domains/assistant/tools/confirm_user_memory_tool.rb 0.00 % 54 42 0 42 0.00 100.00 % 0 0 0
app/domains/assistant/tools/create_interview_round_tool.rb 0.00 % 74 58 0 58 0.00 100.00 % 0 0 0
app/domains/assistant/tools/generate_interview_prep_tool.rb 0.00 % 114 91 0 91 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_interview_application_tool.rb 0.00 % 58 51 0 51 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_interview_feedback_tool.rb 0.00 % 39 33 0 33 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_interview_prep_tool.rb 0.00 % 109 90 0 90 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_next_interview_tool.rb 0.00 % 44 38 0 38 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_profile_summary_tool.rb 0.00 % 105 91 0 91 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_skill_details_tool.rb 0.00 % 95 81 0 81 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_work_experience_tool.rb 0.00 % 64 53 0 53 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_interview_applications_tool.rb 0.00 % 71 54 0 54 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_skills_tool.rb 0.00 % 71 57 0 57 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_target_companies_tool.rb 0.00 % 35 30 0 30 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_target_domains_tool.rb 0.00 % 35 30 0 30 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_target_job_roles_tool.rb 0.00 % 35 30 0 30 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_work_history_tool.rb 0.00 % 61 49 0 49 0.00 100.00 % 0 0 0
app/domains/assistant/tools/remove_target_company_tool.rb 0.00 % 81 61 0 61 0.00 100.00 % 0 0 0
app/domains/assistant/tools/remove_target_domain_tool.rb 0.00 % 81 61 0 61 0.00 100.00 % 0 0 0
app/domains/assistant/tools/remove_target_job_role_tool.rb 0.00 % 79 60 0 60 0.00 100.00 % 0 0 0
app/domains/assistant/tools/update_profile_tool.rb 0.00 % 139 106 0 106 0.00 100.00 % 0 0 0
app/domains/assistant/tools/upsert_interview_feedback_tool.rb 0.00 % 42 33 0 33 0.00 100.00 % 0 0 0
app/domains/signals/contracts/validators/json_schema_validator.rb 0.00 % 62 39 0 39 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/decision_input_builder.rb 0.00 % 219 194 0 194 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/dispatcher.rb 0.00 % 118 104 0 104 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/attach_job_listing_to_opportunity.rb 0.00 % 45 36 0 36 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/base_handler.rb 0.00 % 40 33 0 33 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/create_company_feedback.rb 0.00 % 46 39 0 39 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/create_interview_feedback.rb 0.00 % 37 33 0 33 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/create_opportunity.rb 0.00 % 36 31 0 31 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/create_round.rb 0.00 % 44 37 0 37 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/enqueue_scrape_job_listing.rb 0.00 % 43 35 0 35 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/set_application_status.rb 0.00 % 19 17 0 17 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/set_pipeline_stage.rb 0.00 % 19 17 0 17 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/set_round_result.rb 0.00 % 34 29 0 29 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/update_round.rb 0.00 % 35 30 0 30 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/upsert_job_listing_from_url.rb 0.00 % 53 43 0 43 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/precondition_evaluator.rb 0.00 % 101 78 0 78 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution_runner.rb 0.00 % 186 163 0 163 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/planner.rb 0.00 % 65 48 0 48 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/base_rule.rb 0.00 % 45 35 0 35 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/opportunity_rule.rb 0.00 % 130 113 0 113 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/round_feedback_rule.rb 0.00 % 75 65 0 65 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/scheduling_rule.rb 0.00 % 51 45 0 45 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/status_update_rule.rb 0.00 % 102 96 0 96 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/semantic_validator.rb 0.00 % 79 61 0 61 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/shadow_runner.rb 0.00 % 150 126 0 126 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/step_factory.rb 0.00 % 123 109 0 109 0.00 100.00 % 0 0 0
app/domains/signals/services/facts/canonical_email_event_builder.rb 0.00 % 92 76 0 76 0.00 100.00 % 0 0 0
app/domains/signals/services/facts/email_facts_extractor.rb 0.00 % 158 132 0 132 0.00 100.00 % 0 0 0
app/domains/signals/services/observability/email_pipeline_recorder.rb 0.00 % 166 135 0 135 0.00 100.00 % 0 0 0
app/helpers/application_helper.rb 17.65 % 37 17 3 14 0.18 0.00 % 13 0 13
app/helpers/assistant_helper.rb 30.00 % 34 10 3 7 0.30 0.00 % 4 0 4
app/helpers/billing/entitlements_helper.rb 58.82 % 44 17 10 7 0.59 100.00 % 0 0 0
app/helpers/internal/developer/base_helper.rb 8.07 % 1693 731 59 672 0.08 0.00 % 476 0 476
app/helpers/internal/developer/custom_renderers_helper.rb 14.71 % 88 34 5 29 0.15 0.00 % 16 0 16
app/helpers/internal/developer/dashboard_helper.rb 10.32 % 378 155 16 139 0.10 0.00 % 80 0 80
app/helpers/interview_applications_helper.rb 20.00 % 313 65 13 52 0.20 0.00 % 44 0 44
app/helpers/pagy_helper.rb 100.00 % 9 2 2 0 1.00 100.00 % 0 0 0
app/helpers/text_formatter_helper.rb 15.00 % 603 200 30 170 0.15 0.00 % 88 0 88
app/helpers/turnstile_helper.rb 60.00 % 21 5 3 2 0.60 100.00 % 0 0 0
app/jobs/analyze_resume_job.rb 0.00 % 67 40 0 40 0.00 100.00 % 0 0 0
app/jobs/application_job.rb 0.00 % 69 32 0 32 0.00 100.00 % 0 0 0
app/jobs/assistant_chat_job.rb 0.00 % 94 69 0 69 0.00 100.00 % 0 0 0
app/jobs/assistant_memory_proposer_job.rb 0.00 % 13 9 0 9 0.00 100.00 % 0 0 0
app/jobs/assistant_thread_summarizer_job.rb 0.00 % 12 8 0 8 0.00 100.00 % 0 0 0
app/jobs/assistant_tool_execution_job.rb 0.00 % 82 63 0 63 0.00 100.00 % 0 0 0
app/jobs/assistant_tool_followup_job.rb 0.00 % 64 50 0 50 0.00 100.00 % 0 0 0
app/jobs/billing/process_webhook_event_job.rb 0.00 % 26 19 0 19 0.00 100.00 % 0 0 0
app/jobs/cleanup_stuck_scraping_attempts_job.rb 0.00 % 105 66 0 66 0.00 100.00 % 0 0 0
app/jobs/compute_fit_assessment_job.rb 0.00 % 18 9 0 9 0.00 100.00 % 0 0 0
app/jobs/generate_interview_prep_pack_job.rb 0.00 % 43 31 0 31 0.00 100.00 % 0 0 0
app/jobs/generate_round_prep_job.rb 0.00 % 58 36 0 36 0.00 100.00 % 0 0 0
app/jobs/gmail_sync_all_users_job.rb 0.00 % 26 13 0 13 0.00 100.00 % 0 0 0
app/jobs/gmail_sync_job.rb 0.00 % 82 45 0 45 0.00 100.00 % 0 0 0
app/jobs/process_opportunity_email_job.rb 0.00 % 51 21 0 21 0.00 100.00 % 0 0 0
app/jobs/process_signal_extraction_job.rb 0.00 % 192 126 0 126 0.00 100.00 % 0 0 0
app/jobs/purge_deleted_interview_applications_job.rb 0.00 % 17 9 0 9 0.00 100.00 % 0 0 0
app/jobs/recompute_fit_assessments_for_job_listing_job.rb 0.00 % 27 16 0 16 0.00 100.00 % 0 0 0
app/jobs/recompute_fit_assessments_for_user_job.rb 0.00 % 25 16 0 16 0.00 100.00 % 0 0 0
app/jobs/refresh_oauth_tokens_job.rb 0.00 % 64 35 0 35 0.00 100.00 % 0 0 0
app/jobs/scrape_job_listing_job.rb 0.00 % 228 156 0 156 0.00 100.00 % 0 0 0
app/lib/exception_notifier.rb 0.00 % 173 103 0 103 0.00 100.00 % 0 0 0
app/mailers/application_mailer.rb 0.00 % 16 12 0 12 0.00 100.00 % 0 0 0
app/mailers/connected_account_mailer.rb 0.00 % 17 10 0 10 0.00 100.00 % 0 0 0
app/mailers/passwords_mailer.rb 0.00 % 6 6 0 6 0.00 100.00 % 0 0 0
app/mailers/user_mailer.rb 0.00 % 24 17 0 17 0.00 100.00 % 0 0 0
app/models/ai.rb 100.00 % 17 1 1 0 1.00 100.00 % 0 0 0
app/models/ai/assistant_memory_proposal_prompt.rb 0.00 % 47 35 0 35 0.00 100.00 % 0 0 0
app/models/ai/assistant_system_prompt.rb 0.00 % 74 36 0 36 0.00 100.00 % 0 0 0
app/models/ai/assistant_thread_summary_prompt.rb 0.00 % 47 35 0 35 0.00 100.00 % 0 0 0
app/models/ai/email_extraction_prompt.rb 0.00 % 83 58 0 58 0.00 100.00 % 0 0 0
app/models/ai/email_facts_extraction_prompt.rb 0.00 % 111 90 0 90 0.00 100.00 % 0 0 0
app/models/ai/interview_extraction_prompt.rb 0.00 % 126 91 0 91 0.00 100.00 % 0 0 0
app/models/ai/interview_prep_focus_areas_prompt.rb 0.00 % 63 46 0 46 0.00 100.00 % 0 0 0
app/models/ai/interview_prep_match_prompt.rb 0.00 % 61 44 0 44 0.00 100.00 % 0 0 0
app/models/ai/interview_prep_question_framing_prompt.rb 0.00 % 65 48 0 48 0.00 100.00 % 0 0 0
app/models/ai/interview_prep_strength_positioning_prompt.rb 0.00 % 63 46 0 46 0.00 100.00 % 0 0 0
app/models/ai/job_extraction_prompt.rb 0.00 % 89 62 0 62 0.00 100.00 % 0 0 0
app/models/ai/job_postprocess_prompt.rb 0.00 % 66 51 0 51 0.00 100.00 % 0 0 0
app/models/ai/llm_api_log.rb 47.06 % 323 102 48 54 0.47 0.00 % 44 0 44
app/models/ai/llm_prompt.rb 0.00 % 120 54 0 54 0.00 100.00 % 0 0 0
app/models/ai/resume_skill_extraction_prompt.rb 0.00 % 118 94 0 94 0.00 100.00 % 0 0 0
app/models/ai/round_feedback_extraction_prompt.rb 0.00 % 122 85 0 85 0.00 100.00 % 0 0 0
app/models/ai/round_prep_prompt.rb 0.00 % 150 114 0 114 0.00 100.00 % 0 0 0
app/models/ai/signal_extraction_prompt.rb 0.00 % 133 98 0 98 0.00 100.00 % 0 0 0
app/models/ai/status_extraction_prompt.rb 0.00 % 129 94 0 94 0.00 100.00 % 0 0 0
app/models/application_record.rb 100.00 % 3 2 2 0 1.00 100.00 % 0 0 0
app/models/application_skill_tag.rb 0.00 % 13 7 0 7 0.00 100.00 % 0 0 0
app/models/base_aasm.rb 88.89 % 18 9 8 1 2.00 100.00 % 0 0 0
app/models/billing/customer.rb 0.00 % 27 17 0 17 0.00 100.00 % 0 0 0
app/models/billing/entitlement_grant.rb 0.00 % 45 26 0 26 0.00 100.00 % 0 0 0
app/models/billing/feature.rb 0.00 % 32 21 0 21 0.00 100.00 % 0 0 0
app/models/billing/order.rb 0.00 % 29 17 0 17 0.00 100.00 % 0 0 0
app/models/billing/plan.rb 0.00 % 92 60 0 60 0.00 100.00 % 0 0 0
app/models/billing/plan_entitlement.rb 0.00 % 37 23 0 23 0.00 100.00 % 0 0 0
app/models/billing/provider_mapping.rb 0.00 % 31 20 0 20 0.00 100.00 % 0 0 0
app/models/billing/subscription.rb 0.00 % 45 30 0 30 0.00 100.00 % 0 0 0
app/models/billing/usage_counter.rb 0.00 % 54 32 0 32 0.00 100.00 % 0 0 0
app/models/billing/webhook_event.rb 0.00 % 29 18 0 18 0.00 100.00 % 0 0 0
app/models/blog_post.rb 0.00 % 78 44 0 44 0.00 100.00 % 0 0 0
app/models/category.rb 0.00 % 77 47 0 47 0.00 100.00 % 0 0 0
app/models/company.rb 0.00 % 101 65 0 65 0.00 100.00 % 0 0 0
app/models/company_feedback.rb 0.00 % 72 42 0 42 0.00 100.00 % 0 0 0
app/models/concerns/disableable.rb 72.73 % 33 11 8 3 0.73 100.00 % 0 0 0
app/models/concerns/transitionable.rb 77.78 % 17 9 7 2 1.00 0.00 % 2 0 2
app/models/connected_account.rb 0.00 % 123 74 0 74 0.00 100.00 % 0 0 0
app/models/current.rb 100.00 % 4 3 3 0 1.00 100.00 % 0 0 0
app/models/developer.rb 0.00 % 70 34 0 34 0.00 100.00 % 0 0 0
app/models/domain.rb 0.00 % 34 18 0 18 0.00 100.00 % 0 0 0
app/models/email_sender.rb 0.00 % 158 81 0 81 0.00 100.00 % 0 0 0
app/models/fit_assessment.rb 0.00 % 45 24 0 24 0.00 100.00 % 0 0 0
app/models/html_scraping_log.rb 0.00 % 196 113 0 113 0.00 100.00 % 0 0 0
app/models/interview_application.rb 63.33 % 325 150 95 55 0.63 0.00 % 53 0 53
app/models/interview_feedback.rb 0.00 % 46 25 0 25 0.00 100.00 % 0 0 0
app/models/interview_prep_artifact.rb 0.00 % 40 25 0 25 0.00 100.00 % 0 0 0
app/models/interview_round.rb 0.00 % 135 84 0 84 0.00 100.00 % 0 0 0
app/models/interview_round_prep_artifact.rb 0.00 % 101 44 0 44 0.00 100.00 % 0 0 0
app/models/interview_round_type.rb 0.00 % 66 29 0 29 0.00 100.00 % 0 0 0
app/models/job_listing.rb 47.12 % 228 104 49 55 0.47 0.00 % 56 0 56
app/models/job_role.rb 0.00 % 120 74 0 74 0.00 100.00 % 0 0 0
app/models/llm_provider_config.rb 0.00 % 98 56 0 56 0.00 100.00 % 0 0 0
app/models/newsletter_subscriber.rb 0.00 % 12 5 0 5 0.00 100.00 % 0 0 0
app/models/opportunity.rb 0.00 % 209 131 0 131 0.00 100.00 % 0 0 0
app/models/resume_skill.rb 0.00 % 115 63 0 63 0.00 100.00 % 0 0 0
app/models/resume_work_experience.rb 0.00 % 26 15 0 15 0.00 100.00 % 0 0 0
app/models/resume_work_experience_skill.rb 0.00 % 10 6 0 6 0.00 100.00 % 0 0 0
app/models/saved_job.rb 0.00 % 92 56 0 56 0.00 100.00 % 0 0 0
app/models/scraped_job_listing_data.rb 0.00 % 140 76 0 76 0.00 100.00 % 0 0 0
app/models/scraping_attempt.rb 67.53 % 166 77 52 25 0.68 0.00 % 14 0 14
app/models/scraping_event.rb 0.00 % 210 137 0 137 0.00 100.00 % 0 0 0
app/models/session.rb 0.00 % 3 3 0 3 0.00 100.00 % 0 0 0
app/models/setting.rb 55.77 % 120 52 29 23 7.96 12.50 % 16 2 14
app/models/signals/email_pipeline_event.rb 0.00 % 61 48 0 48 0.00 100.00 % 0 0 0
app/models/signals/email_pipeline_run.rb 0.00 % 38 27 0 27 0.00 100.00 % 0 0 0
app/models/skill_tag.rb 0.00 % 154 96 0 96 0.00 100.00 % 0 0 0
app/models/support_ticket.rb 0.00 % 46 28 0 28 0.00 100.00 % 0 0 0
app/models/synced_email.rb 0.00 % 836 464 0 464 0.00 100.00 % 0 0 0
app/models/transition.rb 0.00 % 3 3 0 3 0.00 100.00 % 0 0 0
app/models/user.rb 74.39 % 195 82 61 21 0.74 0.00 % 6 0 6
app/models/user_preference.rb 0.00 % 87 46 0 46 0.00 100.00 % 0 0 0
app/models/user_resume.rb 0.00 % 199 114 0 114 0.00 100.00 % 0 0 0
app/models/user_resume_target_company.rb 0.00 % 9 5 0 5 0.00 100.00 % 0 0 0
app/models/user_resume_target_job_role.rb 0.00 % 9 5 0 5 0.00 100.00 % 0 0 0
app/models/user_skill.rb 0.00 % 117 57 0 57 0.00 100.00 % 0 0 0
app/models/user_target_company.rb 0.00 % 13 8 0 8 0.00 100.00 % 0 0 0
app/models/user_target_domain.rb 0.00 % 13 8 0 8 0.00 100.00 % 0 0 0
app/models/user_target_job_role.rb 0.00 % 13 8 0 8 0.00 100.00 % 0 0 0
app/models/user_work_experience.rb 0.00 % 36 21 0 21 0.00 100.00 % 0 0 0
app/models/user_work_experience_skill.rb 0.00 % 11 6 0 6 0.00 100.00 % 0 0 0
app/models/user_work_experience_source.rb 0.00 % 9 5 0 5 0.00 100.00 % 0 0 0
app/services/ai/api_logger_service.rb 0.00 % 431 244 0 244 0.00 100.00 % 0 0 0
app/services/ai/error_reporter.rb 0.00 % 37 20 0 20 0.00 100.00 % 0 0 0
app/services/ai/prompt_builder_service.rb 0.00 % 51 27 0 27 0.00 100.00 % 0 0 0
app/services/ai/provider_runner_service.rb 0.00 % 240 169 0 169 0.00 100.00 % 0 0 0
app/services/ai/response_parser_service.rb 0.00 % 42 22 0 22 0.00 100.00 % 0 0 0
app/services/api_fetchers/base_fetcher.rb 0.00 % 116 70 0 70 0.00 100.00 % 0 0 0
app/services/api_fetchers/greenhouse_fetcher.rb 0.00 % 181 115 0 115 0.00 100.00 % 0 0 0
app/services/api_fetchers/lever_fetcher.rb 0.00 % 198 131 0 131 0.00 100.00 % 0 0 0
app/services/application_service.rb 0.00 % 121 45 0 45 0.00 100.00 % 0 0 0
app/services/application_timeline_service.rb 0.00 % 206 158 0 158 0.00 100.00 % 0 0 0
app/services/billing/admin_access_service.rb 0.00 % 93 61 0 61 0.00 100.00 % 0 0 0
app/services/billing/catalog.rb 0.00 % 38 21 0 21 0.00 100.00 % 0 0 0
app/services/billing/debug_snapshot_service.rb 0.00 % 171 124 0 124 0.00 100.00 % 0 0 0
app/services/billing/entitlements.rb 0.00 % 319 188 0 188 0.00 100.00 % 0 0 0
app/services/billing/plan_switcher.rb 0.00 % 152 97 0 97 0.00 100.00 % 0 0 0
app/services/billing/providers/lemon_squeezy.rb 0.00 % 231 166 0 166 0.00 100.00 % 0 0 0
app/services/billing/seed_catalog_service.rb 0.00 % 232 198 0 198 0.00 100.00 % 0 0 0
app/services/billing/trial_unlock_service.rb 0.00 % 95 60 0 60 0.00 100.00 % 0 0 0
app/services/billing/webhooks/lemon_squeezy_processor.rb 0.00 % 478 332 0 332 0.00 100.00 % 0 0 0
app/services/billing/webhooks/processor.rb 0.00 % 29 19 0 19 0.00 100.00 % 0 0 0
app/services/cloudflare_turnstile_service.rb 0.00 % 74 42 0 42 0.00 100.00 % 0 0 0
app/services/compute_fit_assessment_service.rb 0.00 % 177 135 0 135 0.00 100.00 % 0 0 0
app/services/create_job_listing_from_url_service.rb 0.00 % 47 23 0 23 0.00 100.00 % 0 0 0
app/services/dedup/find_category_duplicates_service.rb 0.00 % 37 23 0 23 0.00 100.00 % 0 0 0
app/services/dedup/find_company_duplicates_service.rb 0.00 % 78 55 0 55 0.00 100.00 % 0 0 0
app/services/dedup/find_job_role_duplicates_service.rb 0.00 % 64 43 0 43 0.00 100.00 % 0 0 0
app/services/dedup/find_skill_tag_duplicates_service.rb 0.00 % 64 43 0 43 0.00 100.00 % 0 0 0
app/services/dedup/merge_category_service.rb 0.00 % 61 38 0 38 0.00 100.00 % 0 0 0
app/services/dedup/merge_company_service.rb 0.00 % 98 66 0 66 0.00 100.00 % 0 0 0
app/services/dedup/merge_job_role_service.rb 0.00 % 89 61 0 61 0.00 100.00 % 0 0 0
app/services/dedup/merge_skill_tag_service.rb 0.00 % 85 59 0 59 0.00 100.00 % 0 0 0
app/services/feedback_analysis_service.rb 0.00 % 74 43 0 43 0.00 100.00 % 0 0 0
app/services/gmail/client_service.rb 0.00 % 87 45 0 45 0.00 100.00 % 0 0 0
app/services/gmail/company_matcher_service.rb 0.00 % 209 107 0 107 0.00 100.00 % 0 0 0
app/services/gmail/email_processor_service.rb 0.00 % 631 437 0 437 0.00 100.00 % 0 0 0
app/services/gmail/errors.rb 0.00 % 12 9 0 9 0.00 100.00 % 0 0 0
app/services/gmail/opportunity_detector_service.rb 0.00 % 304 196 0 196 0.00 100.00 % 0 0 0
app/services/gmail/sync_service.rb 0.00 % 788 514 0 514 0.00 100.00 % 0 0 0
app/services/interview_prep/base_generator_service.rb 0.00 % 169 133 0 133 0.00 100.00 % 0 0 0
app/services/interview_prep/generate_focus_areas_service.rb 0.00 % 31 24 0 24 0.00 100.00 % 0 0 0
app/services/interview_prep/generate_match_analysis_service.rb 0.00 % 27 21 0 21 0.00 100.00 % 0 0 0
app/services/interview_prep/generate_question_framing_service.rb 0.00 % 34 26 0 26 0.00 100.00 % 0 0 0
app/services/interview_prep/generate_strength_positioning_service.rb 0.00 % 33 25 0 25 0.00 100.00 % 0 0 0
app/services/interview_prep/inputs_builder_service.rb 0.00 % 133 102 0 102 0.00 100.00 % 0 0 0
app/services/interview_round_prep/company_patterns_service.rb 0.00 % 196 118 0 118 0.00 100.00 % 0 0 0
app/services/interview_round_prep/generate_service.rb 0.00 % 180 123 0 123 0.00 100.00 % 0 0 0
app/services/interview_round_prep/historical_analyzer_service.rb 0.00 % 187 114 0 114 0.00 100.00 % 0 0 0
app/services/interview_round_prep/inputs_builder_service.rb 0.00 % 170 113 0 113 0.00 100.00 % 0 0 0
app/services/job_listing_scraper_service.rb 0.00 % 112 75 0 75 0.00 100.00 % 0 0 0
app/services/job_listings/upsert_from_url_service.rb 0.00 % 72 49 0 49 0.00 100.00 % 0 0 0
app/services/labels/dedupe_service.rb 0.00 % 141 89 0 89 0.00 100.00 % 0 0 0
app/services/llm_providers/anthropic_provider.rb 0.00 % 610 432 0 432 0.00 100.00 % 0 0 0
app/services/llm_providers/base_provider.rb 0.00 % 238 113 0 113 0.00 100.00 % 0 0 0
app/services/llm_providers/ollama_provider.rb 0.00 % 141 109 0 109 0.00 100.00 % 0 0 0
app/services/llm_providers/openai_provider.rb 0.00 % 463 317 0 317 0.00 100.00 % 0 0 0
app/services/llm_providers/provider_config_helper.rb 0.00 % 38 15 0 15 0.00 100.00 % 0 0 0
app/services/markdown_renderer.rb 0.00 % 150 90 0 90 0.00 100.00 % 0 0 0
app/services/oauth_authentication_service.rb 0.00 % 99 51 0 51 0.00 100.00 % 0 0 0
app/services/opportunities/create_application_service.rb 0.00 % 263 142 0 142 0.00 100.00 % 0 0 0
app/services/opportunities/extraction_service.rb 0.00 % 231 143 0 143 0.00 100.00 % 0 0 0
app/services/profile_insights_service.rb 0.00 % 194 133 0 133 0.00 100.00 % 0 0 0
app/services/quick_apply_from_url_service.rb 0.00 % 343 174 0 174 0.00 100.00 % 0 0 0
app/services/resumes/ai_skill_extractor_service.rb 0.00 % 501 320 0 320 0.00 100.00 % 0 0 0
app/services/resumes/analysis_service.rb 0.00 % 360 208 0 208 0.00 100.00 % 0 0 0
app/services/resumes/skill_aggregation_service.rb 0.00 % 217 129 0 129 0.00 100.00 % 0 0 0
app/services/resumes/text_extractor_service.rb 0.00 % 186 113 0 113 0.00 100.00 % 0 0 0
app/services/resumes/work_history_aggregation_service.rb 0.00 % 107 80 0 80 0.00 100.00 % 0 0 0
app/services/scraping/ai_job_extractor_service.rb 0.00 % 245 184 0 184 0.00 100.00 % 0 0 0
app/services/scraping/ai_job_post_processor_service.rb 0.00 % 140 110 0 110 0.00 100.00 % 0 0 0
app/services/scraping/anthropic_rate_limiter_service.rb 0.00 % 112 52 0 52 0.00 100.00 % 0 0 0
app/services/scraping/concerns/loggable.rb 0.00 % 68 33 0 33 0.00 100.00 % 0 0 0
app/services/scraping/event_recorder_service.rb 0.00 % 264 148 0 148 0.00 100.00 % 0 0 0
app/services/scraping/failure_classifier_service.rb 0.00 % 61 32 0 32 0.00 100.00 % 0 0 0
app/services/scraping/html_cleaners/ashby_cleaner.rb 0.00 % 62 40 0 40 0.00 100.00 % 0 0 0
app/services/scraping/html_cleaners/base_cleaner.rb 0.00 % 124 84 0 84 0.00 100.00 % 0 0 0
app/services/scraping/html_cleaners/cleaner_factory.rb 0.00 % 44 23 0 23 0.00 100.00 % 0 0 0
app/services/scraping/html_fetcher_service.rb 0.00 % 188 123 0 123 0.00 100.00 % 0 0 0
app/services/scraping/html_scraping_service.rb 0.00 % 567 392 0 392 0.00 100.00 % 0 0 0
app/services/scraping/job_board_detector_service.rb 0.00 % 229 132 0 132 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/ashby_extractor.rb 0.00 % 148 85 0 85 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/bamboo_hr_extractor.rb 0.00 % 32 27 0 27 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/base_extractor.rb 0.00 % 163 126 0 126 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/extractor_factory.rb 0.00 % 31 28 0 28 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/greenhouse_extractor.rb 0.00 % 64 56 0 56 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/icims_extractor.rb 0.00 % 33 28 0 28 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/jobvite_extractor.rb 0.00 % 39 33 0 33 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/lever_extractor.rb 0.00 % 39 33 0 33 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/smart_recruiters_extractor.rb 0.00 % 40 34 0 34 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/workable_extractor.rb 0.00 % 40 34 0 34 0.00 100.00 % 0 0 0
app/services/scraping/nokogiri_html_cleaner_service.rb 0.00 % 177 69 0 69 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/context.rb 0.00 % 34 26 0 26 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/runner.rb 0.00 % 48 40 0 40 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/ai_extract.rb 0.00 % 108 91 0 91 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/api_extract.rb 0.00 % 130 106 0 106 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/base_step.rb 0.00 % 27 21 0 21 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/detect_job_board.rb 0.00 % 31 27 0 27 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/fetch_html.rb 0.00 % 38 32 0 32 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/handle_limited_sources.rb 0.00 % 182 125 0 125 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/nokogiri_scrape.rb 0.00 % 39 36 0 36 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/rendered_fallback.rb 0.00 % 89 80 0 80 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/resolve_embedded_job_board.rb 0.00 % 154 123 0 123 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/selectors_extract.rb 0.00 % 55 47 0 47 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/support/attempt_lifecycle.rb 0.00 % 146 112 0 112 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/support/entity_resolver.rb 0.00 % 235 185 0 185 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/support/job_listing_updater.rb 0.00 % 118 96 0 96 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/support/observability.rb 0.00 % 103 86 0 86 0.00 100.00 % 0 0 0
app/services/scraping/orchestrator_service.rb 0.00 % 46 30 0 30 0.00 100.00 % 0 0 0
app/services/scraping/rate_limiter_service.rb 0.00 % 101 50 0 50 0.00 100.00 % 0 0 0
app/services/scraping/rendered_html_fetcher_service.rb 0.00 % 319 234 0 234 0.00 100.00 % 0 0 0
app/services/scraping/retry_service.rb 0.00 % 255 164 0 164 0.00 100.00 % 0 0 0
app/services/scraping/robots_txt_checker_service.rb 0.00 % 98 57 0 57 0.00 100.00 % 0 0 0
app/services/scraping/salary_range_validator.rb 0.00 % 107 67 0 67 0.00 100.00 % 0 0 0
app/services/signals/action_applier.rb 0.00 % 134 112 0 112 0.00 100.00 % 0 0 0
app/services/signals/action_executor.rb 0.00 % 97 47 0 47 0.00 100.00 % 0 0 0
app/services/signals/actions/base_action.rb 0.00 % 124 55 0 55 0.00 100.00 % 0 0 0
app/services/signals/actions/start_application_action.rb 0.00 % 273 152 0 152 0.00 100.00 % 0 0 0
app/services/signals/application_status_processor.rb 0.00 % 502 331 0 331 0.00 100.00 % 0 0 0
app/services/signals/company_feedback_processor.rb 0.00 % 161 92 0 92 0.00 100.00 % 0 0 0
app/services/signals/email_state_orchestrator.rb 0.00 % 86 70 0 70 0.00 100.00 % 0 0 0
app/services/signals/extraction_service.rb 0.00 % 488 301 0 301 0.00 100.00 % 0 0 0
app/services/signals/interview_round_processor.rb 0.00 % 439 280 0 280 0.00 100.00 % 0 0 0
app/services/signals/round_feedback_processor.rb 0.00 % 491 329 0 329 0.00 100.00 % 0 0 0
app/services/signals/rules/application_confirmation_rule.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/services/signals/rules/base_rule.rb 0.00 % 54 46 0 46 0.00 100.00 % 0 0 0
app/services/signals/rules/offer_rule.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/services/signals/rules/rejection_rule.rb 0.00 % 20 16 0 16 0.00 100.00 % 0 0 0
app/services/signals/rules/round_feedback_rule.rb 0.00 % 20 16 0 16 0.00 100.00 % 0 0 0
app/services/signals/rules/scheduling_rule.rb 0.00 % 22 17 0 17 0.00 100.00 % 0 0 0
app/services/signals/state_context.rb 0.00 % 21 16 0 16 0.00 100.00 % 0 0 0
app/services/signals/state_transition_planner.rb 0.00 % 40 33 0 33 0.00 100.00 % 0 0 0
gems/admin_suite/app/controllers/admin_suite/application_controller.rb 67.31 % 117 52 35 17 99.54 44.44 % 18 8 10
gems/admin_suite/app/controllers/admin_suite/dashboard_controller.rb 80.77 % 258 104 84 20 2.82 46.15 % 52 24 28
gems/admin_suite/app/controllers/admin_suite/docs_controller.rb 85.88 % 153 85 73 12 17.31 66.67 % 24 16 8
gems/admin_suite/app/helpers/admin_suite/base_helper.rb 10.76 % 1199 632 68 564 0.85 0.51 % 392 2 390
gems/admin_suite/app/helpers/admin_suite/icon_helper.rb 85.00 % 61 20 17 3 79.95 50.00 % 12 6 6
gems/admin_suite/app/helpers/admin_suite/panels_helper.rb 100.00 % 52 22 22 0 27.50 70.00 % 10 7 3
gems/admin_suite/app/helpers/admin_suite/theme_helper.rb 73.81 % 99 42 31 11 3.02 50.00 % 10 5 5
gems/admin_suite/config/routes.rb 100.00 % 29 17 17 0 1.00 100.00 % 0 0 0
gems/admin_suite/lib/admin/base/action_executor.rb 30.68 % 155 88 27 61 0.31 0.00 % 40 0 40
gems/admin_suite/lib/admin/base/action_handler.rb 62.50 % 31 16 10 6 0.63 100.00 % 0 0 0
gems/admin_suite/lib/admin/base/filter_builder.rb 20.29 % 121 69 14 55 0.20 0.00 % 46 0 46
gems/admin_suite/lib/admin/base/resource.rb 48.59 % 541 177 86 91 1.39 0.00 % 54 0 54
gems/admin_suite/lib/admin_suite.rb 95.83 % 52 24 23 1 22.96 50.00 % 2 1 1
gems/admin_suite/lib/admin_suite/configuration.rb 100.00 % 42 20 20 0 1.00 100.00 % 0 0 0
gems/admin_suite/lib/admin_suite/engine.rb 70.37 % 61 27 19 8 0.70 42.86 % 14 6 8
gems/admin_suite/lib/admin_suite/markdown_renderer.rb 92.00 % 115 50 46 4 9.90 75.00 % 8 6 2
gems/admin_suite/lib/admin_suite/portal_definition.rb 97.14 % 64 35 34 1 22.51 50.00 % 12 6 6
gems/admin_suite/lib/admin_suite/portal_registry.rb 81.82 % 32 11 9 2 26.09 100.00 % 0 0 0
gems/admin_suite/lib/admin_suite/theme_palette.rb 100.00 % 36 12 12 0 13.83 50.00 % 4 2 2
gems/admin_suite/lib/admin_suite/ui/dashboard_definition.rb 97.30 % 69 37 36 1 10.49 35.71 % 14 5 9
gems/admin_suite/lib/admin_suite/ui/field_renderer_registry.rb 53.33 % 119 60 32 28 1.27 0.00 % 4 0 4
gems/admin_suite/lib/admin_suite/ui/form_field_renderer.rb 25.00 % 48 20 5 15 0.25 0.00 % 16 0 16
gems/admin_suite/lib/admin_suite/ui/show_formatter_registry.rb 38.10 % 118 63 24 39 0.70 0.00 % 14 0 14
gems/admin_suite/lib/admin_suite/ui/show_value_formatter.rb 16.67 % 70 30 5 25 0.17 0.00 % 30 0 30
lib/omniauth/strategies/techwright.rb 45.45 % 127 33 15 18 0.45 0.00 % 2 0 2

Controllers ( 3.5% covered at 1.07 hits/line )

111 files in total.
6569 relevant lines, 230 lines covered and 6339 lines missed. ( 3.5% )
138 total branches, 51 branches covered and 87 branches missed. ( 36.96% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/controllers/ai_assistant/queries_controller.rb 0.00 % 72 57 0 57 0.00 100.00 % 0 0 0
app/controllers/ai_assistant/tool_executions_controller.rb 0.00 % 61 45 0 45 0.00 100.00 % 0 0 0
app/controllers/api/v1/base_controller.rb 0.00 % 22 9 0 9 0.00 100.00 % 0 0 0
app/controllers/api/v1/companies_controller.rb 0.00 % 53 33 0 33 0.00 100.00 % 0 0 0
app/controllers/api/v1/departments_controller.rb 0.00 % 27 16 0 16 0.00 100.00 % 0 0 0
app/controllers/api/v1/domains_controller.rb 0.00 % 53 33 0 33 0.00 100.00 % 0 0 0
app/controllers/api/v1/job_roles_controller.rb 0.00 % 63 40 0 40 0.00 100.00 % 0 0 0
app/controllers/application_controller.rb 71.43 % 27 14 10 4 0.71 0.00 % 2 0 2
app/controllers/archived_jobs_controller.rb 0.00 % 13 9 0 9 0.00 100.00 % 0 0 0
app/controllers/assistant/messages_controller.rb 0.00 % 121 75 0 75 0.00 100.00 % 0 0 0
app/controllers/assistant/threads_controller.rb 0.00 % 22 17 0 17 0.00 100.00 % 0 0 0
app/controllers/assistant/tool_executions_controller.rb 0.00 % 279 232 0 232 0.00 100.00 % 0 0 0
app/controllers/assistant/widgets_controller.rb 0.00 % 66 46 0 46 0.00 100.00 % 0 0 0
app/controllers/billing/checkouts_controller.rb 0.00 % 42 28 0 28 0.00 100.00 % 0 0 0
app/controllers/billing/portal_controller.rb 0.00 % 28 19 0 19 0.00 100.00 % 0 0 0
app/controllers/billing/returns_controller.rb 0.00 % 20 12 0 12 0.00 100.00 % 0 0 0
app/controllers/billing/subscriptions_controller.rb 0.00 % 69 50 0 50 0.00 100.00 % 0 0 0
app/controllers/categories_controller.rb 0.00 % 65 46 0 46 0.00 100.00 % 0 0 0
app/controllers/companies_controller.rb 0.00 % 83 58 0 58 0.00 100.00 % 0 0 0
app/controllers/company_feedbacks_controller.rb 0.00 % 80 58 0 58 0.00 100.00 % 0 0 0
app/controllers/concerns/authentication.rb 38.10 % 125 63 24 39 0.86 7.14 % 42 3 39
app/controllers/concerns/paginatable.rb 100.00 % 24 4 4 0 1.00 100.00 % 0 0 0
app/controllers/dashboard_controller.rb 0.00 % 190 136 0 136 0.00 100.00 % 0 0 0
app/controllers/email_verifications_controller.rb 0.00 % 46 32 0 32 0.00 100.00 % 0 0 0
app/controllers/inbox_controller.rb 0.00 % 358 243 0 243 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/base_controller.rb 0.00 % 23 16 0 16 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/dashboard_controller.rb 0.00 % 104 83 0 83 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/llm_api_logs_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/llm_prompts_controller.rb 0.00 % 46 35 0 35 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ai/llm_provider_configs_controller.rb 0.00 % 48 36 0 36 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/base_controller.rb 0.00 % 26 18 0 18 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/dashboard_controller.rb 0.00 % 104 83 0 83 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/events_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/memory_proposals_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/thread_summaries_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/threads_controller.rb 0.00 % 27 20 0 20 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/tool_executions_controller.rb 0.00 % 82 65 0 65 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/tools_controller.rb 0.00 % 48 36 0 36 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/turns_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/assistant/user_memories_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/base_controller.rb 0.00 % 182 108 0 108 0.00 100.00 % 0 0 0
app/controllers/internal/developer/dashboard_controller.rb 0.00 % 195 143 0 143 0.00 100.00 % 0 0 0
app/controllers/internal/developer/docs_controller.rb 0.00 % 163 127 0 127 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/base_controller.rb 0.00 % 22 16 0 16 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/dashboard_controller.rb 0.00 % 70 58 0 58 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/email_pipeline_events_controller.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/email_pipeline_runs_controller.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/email/synced_emails_controller.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/base_controller.rb 0.00 % 23 16 0 16 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/blog_posts_controller.rb 0.00 % 43 33 0 33 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/categories_controller.rb 0.00 % 50 38 0 38 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/companies_controller.rb 0.00 % 50 38 0 38 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/company_feedbacks_controller.rb 0.00 % 21 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/connected_accounts_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/dashboard_controller.rb 0.00 % 111 88 0 88 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/email_senders_controller.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/html_scraping_logs_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/interview_applications_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/interview_round_types_controller.rb 0.00 % 45 32 0 32 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/interview_rounds_controller.rb 0.00 % 21 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/job_listings_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/job_roles_controller.rb 0.00 % 50 38 0 38 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/scraping_attempts_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/scraping_events_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/settings_controller.rb 0.00 % 37 28 0 28 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/skill_tags_controller.rb 0.00 % 49 38 0 38 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/support_tickets_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/synced_emails_controller.rb 0.00 % 20 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/ops/users_controller.rb 0.00 % 63 46 0 46 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/dashboard_controller.rb 0.00 % 32 23 0 23 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/features_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/plan_entitlements_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/plans_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/provider_mappings_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/subscriptions_controller.rb 0.00 % 22 15 0 15 0.00 100.00 % 0 0 0
app/controllers/internal/developer/payments/webhook_events_controller.rb 0.00 % 29 20 0 20 0.00 100.00 % 0 0 0
app/controllers/internal/developer/resources_controller.rb 0.00 % 348 218 0 218 0.00 100.00 % 0 0 0
app/controllers/internal/developer/sessions_controller.rb 0.00 % 126 73 0 73 0.00 100.00 % 0 0 0
app/controllers/interview_application_preps_controller.rb 0.00 % 38 29 0 29 0.00 100.00 % 0 0 0
app/controllers/interview_applications_controller.rb 0.00 % 463 364 0 364 0.00 100.00 % 0 0 0
app/controllers/interview_feedbacks_controller.rb 0.00 % 94 70 0 70 0.00 100.00 % 0 0 0
app/controllers/interview_round_preps_controller.rb 0.00 % 84 60 0 60 0.00 100.00 % 0 0 0
app/controllers/interview_rounds_controller.rb 0.00 % 115 83 0 83 0.00 100.00 % 0 0 0
app/controllers/job_listings_controller.rb 0.00 % 148 116 0 116 0.00 100.00 % 0 0 0
app/controllers/job_roles_controller.rb 0.00 % 87 61 0 61 0.00 100.00 % 0 0 0
app/controllers/oauth_callbacks_controller.rb 0.00 % 98 60 0 60 0.00 100.00 % 0 0 0
app/controllers/opportunities_controller.rb 0.00 % 262 204 0 204 0.00 100.00 % 0 0 0
app/controllers/passwords_controller.rb 0.00 % 36 30 0 30 0.00 100.00 % 0 0 0
app/controllers/profiles_controller.rb 0.00 % 52 40 0 40 0.00 100.00 % 0 0 0
app/controllers/public/base_controller.rb 0.00 % 55 28 0 28 0.00 100.00 % 0 0 0
app/controllers/public/blog_controller.rb 0.00 % 93 79 0 79 0.00 100.00 % 0 0 0
app/controllers/public/blog_tags_controller.rb 0.00 % 41 30 0 30 0.00 100.00 % 0 0 0
app/controllers/public/contacts_controller.rb 0.00 % 59 32 0 32 0.00 100.00 % 0 0 0
app/controllers/public/home_controller.rb 0.00 % 19 6 0 6 0.00 100.00 % 0 0 0
app/controllers/public/legal_controller.rb 0.00 % 26 14 0 14 0.00 100.00 % 0 0 0
app/controllers/public/newsletter_subscriptions_controller.rb 0.00 % 35 24 0 24 0.00 100.00 % 0 0 0
app/controllers/public/pricing_controller.rb 0.00 % 13 7 0 7 0.00 100.00 % 0 0 0
app/controllers/public/sitemaps_controller.rb 0.00 % 18 14 0 14 0.00 100.00 % 0 0 0
app/controllers/registrations_controller.rb 0.00 % 52 34 0 34 0.00 100.00 % 0 0 0
app/controllers/resume_skills_controller.rb 0.00 % 176 128 0 128 0.00 100.00 % 0 0 0
app/controllers/saved_jobs_controller.rb 0.00 % 104 78 0 78 0.00 100.00 % 0 0 0
app/controllers/sessions_controller.rb 0.00 % 33 29 0 29 0.00 100.00 % 0 0 0
app/controllers/settings_controller.rb 0.00 % 729 502 0 502 0.00 100.00 % 0 0 0
app/controllers/signals_controller.rb 0.00 % 627 450 0 450 0.00 100.00 % 0 0 0
app/controllers/skill_tags_controller.rb 0.00 % 59 41 0 41 0.00 100.00 % 0 0 0
app/controllers/skills_controller.rb 0.00 % 182 133 0 133 0.00 100.00 % 0 0 0
app/controllers/user_resumes_controller.rb 0.00 % 290 208 0 208 0.00 100.00 % 0 0 0
app/controllers/webhooks/lemon_squeezy_controller.rb 0.00 % 70 51 0 51 0.00 100.00 % 0 0 0
gems/admin_suite/app/controllers/admin_suite/application_controller.rb 67.31 % 117 52 35 17 99.54 44.44 % 18 8 10
gems/admin_suite/app/controllers/admin_suite/dashboard_controller.rb 80.77 % 258 104 84 20 2.82 46.15 % 52 24 28
gems/admin_suite/app/controllers/admin_suite/docs_controller.rb 85.88 % 153 85 73 12 17.31 66.67 % 24 16 8

Channels ( 0.0% covered at 0.0 hits/line )

1 files in total.
64 relevant lines, 0 lines covered and 64 lines missed. ( 0.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/channels/application_cable/connection.rb 0.00 % 101 64 0 64 0.00 100.00 % 0 0 0

Models ( 8.3% covered at 0.17 hits/line )

84 files in total.
4374 relevant lines, 363 lines covered and 4011 lines missed. ( 8.3% )
191 total branches, 2 branches covered and 189 branches missed. ( 1.05% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/models/ai.rb 100.00 % 17 1 1 0 1.00 100.00 % 0 0 0
app/models/ai/assistant_memory_proposal_prompt.rb 0.00 % 47 35 0 35 0.00 100.00 % 0 0 0
app/models/ai/assistant_system_prompt.rb 0.00 % 74 36 0 36 0.00 100.00 % 0 0 0
app/models/ai/assistant_thread_summary_prompt.rb 0.00 % 47 35 0 35 0.00 100.00 % 0 0 0
app/models/ai/email_extraction_prompt.rb 0.00 % 83 58 0 58 0.00 100.00 % 0 0 0
app/models/ai/email_facts_extraction_prompt.rb 0.00 % 111 90 0 90 0.00 100.00 % 0 0 0
app/models/ai/interview_extraction_prompt.rb 0.00 % 126 91 0 91 0.00 100.00 % 0 0 0
app/models/ai/interview_prep_focus_areas_prompt.rb 0.00 % 63 46 0 46 0.00 100.00 % 0 0 0
app/models/ai/interview_prep_match_prompt.rb 0.00 % 61 44 0 44 0.00 100.00 % 0 0 0
app/models/ai/interview_prep_question_framing_prompt.rb 0.00 % 65 48 0 48 0.00 100.00 % 0 0 0
app/models/ai/interview_prep_strength_positioning_prompt.rb 0.00 % 63 46 0 46 0.00 100.00 % 0 0 0
app/models/ai/job_extraction_prompt.rb 0.00 % 89 62 0 62 0.00 100.00 % 0 0 0
app/models/ai/job_postprocess_prompt.rb 0.00 % 66 51 0 51 0.00 100.00 % 0 0 0
app/models/ai/llm_api_log.rb 47.06 % 323 102 48 54 0.47 0.00 % 44 0 44
app/models/ai/llm_prompt.rb 0.00 % 120 54 0 54 0.00 100.00 % 0 0 0
app/models/ai/resume_skill_extraction_prompt.rb 0.00 % 118 94 0 94 0.00 100.00 % 0 0 0
app/models/ai/round_feedback_extraction_prompt.rb 0.00 % 122 85 0 85 0.00 100.00 % 0 0 0
app/models/ai/round_prep_prompt.rb 0.00 % 150 114 0 114 0.00 100.00 % 0 0 0
app/models/ai/signal_extraction_prompt.rb 0.00 % 133 98 0 98 0.00 100.00 % 0 0 0
app/models/ai/status_extraction_prompt.rb 0.00 % 129 94 0 94 0.00 100.00 % 0 0 0
app/models/application_record.rb 100.00 % 3 2 2 0 1.00 100.00 % 0 0 0
app/models/application_skill_tag.rb 0.00 % 13 7 0 7 0.00 100.00 % 0 0 0
app/models/base_aasm.rb 88.89 % 18 9 8 1 2.00 100.00 % 0 0 0
app/models/billing/customer.rb 0.00 % 27 17 0 17 0.00 100.00 % 0 0 0
app/models/billing/entitlement_grant.rb 0.00 % 45 26 0 26 0.00 100.00 % 0 0 0
app/models/billing/feature.rb 0.00 % 32 21 0 21 0.00 100.00 % 0 0 0
app/models/billing/order.rb 0.00 % 29 17 0 17 0.00 100.00 % 0 0 0
app/models/billing/plan.rb 0.00 % 92 60 0 60 0.00 100.00 % 0 0 0
app/models/billing/plan_entitlement.rb 0.00 % 37 23 0 23 0.00 100.00 % 0 0 0
app/models/billing/provider_mapping.rb 0.00 % 31 20 0 20 0.00 100.00 % 0 0 0
app/models/billing/subscription.rb 0.00 % 45 30 0 30 0.00 100.00 % 0 0 0
app/models/billing/usage_counter.rb 0.00 % 54 32 0 32 0.00 100.00 % 0 0 0
app/models/billing/webhook_event.rb 0.00 % 29 18 0 18 0.00 100.00 % 0 0 0
app/models/blog_post.rb 0.00 % 78 44 0 44 0.00 100.00 % 0 0 0
app/models/category.rb 0.00 % 77 47 0 47 0.00 100.00 % 0 0 0
app/models/company.rb 0.00 % 101 65 0 65 0.00 100.00 % 0 0 0
app/models/company_feedback.rb 0.00 % 72 42 0 42 0.00 100.00 % 0 0 0
app/models/concerns/disableable.rb 72.73 % 33 11 8 3 0.73 100.00 % 0 0 0
app/models/concerns/transitionable.rb 77.78 % 17 9 7 2 1.00 0.00 % 2 0 2
app/models/connected_account.rb 0.00 % 123 74 0 74 0.00 100.00 % 0 0 0
app/models/current.rb 100.00 % 4 3 3 0 1.00 100.00 % 0 0 0
app/models/developer.rb 0.00 % 70 34 0 34 0.00 100.00 % 0 0 0
app/models/domain.rb 0.00 % 34 18 0 18 0.00 100.00 % 0 0 0
app/models/email_sender.rb 0.00 % 158 81 0 81 0.00 100.00 % 0 0 0
app/models/fit_assessment.rb 0.00 % 45 24 0 24 0.00 100.00 % 0 0 0
app/models/html_scraping_log.rb 0.00 % 196 113 0 113 0.00 100.00 % 0 0 0
app/models/interview_application.rb 63.33 % 325 150 95 55 0.63 0.00 % 53 0 53
app/models/interview_feedback.rb 0.00 % 46 25 0 25 0.00 100.00 % 0 0 0
app/models/interview_prep_artifact.rb 0.00 % 40 25 0 25 0.00 100.00 % 0 0 0
app/models/interview_round.rb 0.00 % 135 84 0 84 0.00 100.00 % 0 0 0
app/models/interview_round_prep_artifact.rb 0.00 % 101 44 0 44 0.00 100.00 % 0 0 0
app/models/interview_round_type.rb 0.00 % 66 29 0 29 0.00 100.00 % 0 0 0
app/models/job_listing.rb 47.12 % 228 104 49 55 0.47 0.00 % 56 0 56
app/models/job_role.rb 0.00 % 120 74 0 74 0.00 100.00 % 0 0 0
app/models/llm_provider_config.rb 0.00 % 98 56 0 56 0.00 100.00 % 0 0 0
app/models/newsletter_subscriber.rb 0.00 % 12 5 0 5 0.00 100.00 % 0 0 0
app/models/opportunity.rb 0.00 % 209 131 0 131 0.00 100.00 % 0 0 0
app/models/resume_skill.rb 0.00 % 115 63 0 63 0.00 100.00 % 0 0 0
app/models/resume_work_experience.rb 0.00 % 26 15 0 15 0.00 100.00 % 0 0 0
app/models/resume_work_experience_skill.rb 0.00 % 10 6 0 6 0.00 100.00 % 0 0 0
app/models/saved_job.rb 0.00 % 92 56 0 56 0.00 100.00 % 0 0 0
app/models/scraped_job_listing_data.rb 0.00 % 140 76 0 76 0.00 100.00 % 0 0 0
app/models/scraping_attempt.rb 67.53 % 166 77 52 25 0.68 0.00 % 14 0 14
app/models/scraping_event.rb 0.00 % 210 137 0 137 0.00 100.00 % 0 0 0
app/models/session.rb 0.00 % 3 3 0 3 0.00 100.00 % 0 0 0
app/models/setting.rb 55.77 % 120 52 29 23 7.96 12.50 % 16 2 14
app/models/signals/email_pipeline_event.rb 0.00 % 61 48 0 48 0.00 100.00 % 0 0 0
app/models/signals/email_pipeline_run.rb 0.00 % 38 27 0 27 0.00 100.00 % 0 0 0
app/models/skill_tag.rb 0.00 % 154 96 0 96 0.00 100.00 % 0 0 0
app/models/support_ticket.rb 0.00 % 46 28 0 28 0.00 100.00 % 0 0 0
app/models/synced_email.rb 0.00 % 836 464 0 464 0.00 100.00 % 0 0 0
app/models/transition.rb 0.00 % 3 3 0 3 0.00 100.00 % 0 0 0
app/models/user.rb 74.39 % 195 82 61 21 0.74 0.00 % 6 0 6
app/models/user_preference.rb 0.00 % 87 46 0 46 0.00 100.00 % 0 0 0
app/models/user_resume.rb 0.00 % 199 114 0 114 0.00 100.00 % 0 0 0
app/models/user_resume_target_company.rb 0.00 % 9 5 0 5 0.00 100.00 % 0 0 0
app/models/user_resume_target_job_role.rb 0.00 % 9 5 0 5 0.00 100.00 % 0 0 0
app/models/user_skill.rb 0.00 % 117 57 0 57 0.00 100.00 % 0 0 0
app/models/user_target_company.rb 0.00 % 13 8 0 8 0.00 100.00 % 0 0 0
app/models/user_target_domain.rb 0.00 % 13 8 0 8 0.00 100.00 % 0 0 0
app/models/user_target_job_role.rb 0.00 % 13 8 0 8 0.00 100.00 % 0 0 0
app/models/user_work_experience.rb 0.00 % 36 21 0 21 0.00 100.00 % 0 0 0
app/models/user_work_experience_skill.rb 0.00 % 11 6 0 6 0.00 100.00 % 0 0 0
app/models/user_work_experience_source.rb 0.00 % 9 5 0 5 0.00 100.00 % 0 0 0

Mailers ( 0.0% covered at 0.0 hits/line )

4 files in total.
45 relevant lines, 0 lines covered and 45 lines missed. ( 0.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/mailers/application_mailer.rb 0.00 % 16 12 0 12 0.00 100.00 % 0 0 0
app/mailers/connected_account_mailer.rb 0.00 % 17 10 0 10 0.00 100.00 % 0 0 0
app/mailers/passwords_mailer.rb 0.00 % 6 6 0 6 0.00 100.00 % 0 0 0
app/mailers/user_mailer.rb 0.00 % 24 17 0 17 0.00 100.00 % 0 0 0

Helpers ( 14.45% covered at 1.54 hits/line )

14 files in total.
1952 relevant lines, 282 lines covered and 1670 lines missed. ( 14.45% )
1145 total branches, 20 branches covered and 1125 branches missed. ( 1.75% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/helpers/application_helper.rb 17.65 % 37 17 3 14 0.18 0.00 % 13 0 13
app/helpers/assistant_helper.rb 30.00 % 34 10 3 7 0.30 0.00 % 4 0 4
app/helpers/billing/entitlements_helper.rb 58.82 % 44 17 10 7 0.59 100.00 % 0 0 0
app/helpers/internal/developer/base_helper.rb 8.07 % 1693 731 59 672 0.08 0.00 % 476 0 476
app/helpers/internal/developer/custom_renderers_helper.rb 14.71 % 88 34 5 29 0.15 0.00 % 16 0 16
app/helpers/internal/developer/dashboard_helper.rb 10.32 % 378 155 16 139 0.10 0.00 % 80 0 80
app/helpers/interview_applications_helper.rb 20.00 % 313 65 13 52 0.20 0.00 % 44 0 44
app/helpers/pagy_helper.rb 100.00 % 9 2 2 0 1.00 100.00 % 0 0 0
app/helpers/text_formatter_helper.rb 15.00 % 603 200 30 170 0.15 0.00 % 88 0 88
app/helpers/turnstile_helper.rb 60.00 % 21 5 3 2 0.60 100.00 % 0 0 0
gems/admin_suite/app/helpers/admin_suite/base_helper.rb 10.76 % 1199 632 68 564 0.85 0.51 % 392 2 390
gems/admin_suite/app/helpers/admin_suite/icon_helper.rb 85.00 % 61 20 17 3 79.95 50.00 % 12 6 6
gems/admin_suite/app/helpers/admin_suite/panels_helper.rb 100.00 % 52 22 22 0 27.50 70.00 % 10 7 3
gems/admin_suite/app/helpers/admin_suite/theme_helper.rb 73.81 % 99 42 31 11 3.02 50.00 % 10 5 5

Jobs ( 0.0% covered at 0.0 hits/line )

21 files in total.
869 relevant lines, 0 lines covered and 869 lines missed. ( 0.0% )
0 total branches, 0 branches covered and 0 branches missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/jobs/analyze_resume_job.rb 0.00 % 67 40 0 40 0.00 100.00 % 0 0 0
app/jobs/application_job.rb 0.00 % 69 32 0 32 0.00 100.00 % 0 0 0
app/jobs/assistant_chat_job.rb 0.00 % 94 69 0 69 0.00 100.00 % 0 0 0
app/jobs/assistant_memory_proposer_job.rb 0.00 % 13 9 0 9 0.00 100.00 % 0 0 0
app/jobs/assistant_thread_summarizer_job.rb 0.00 % 12 8 0 8 0.00 100.00 % 0 0 0
app/jobs/assistant_tool_execution_job.rb 0.00 % 82 63 0 63 0.00 100.00 % 0 0 0
app/jobs/assistant_tool_followup_job.rb 0.00 % 64 50 0 50 0.00 100.00 % 0 0 0
app/jobs/billing/process_webhook_event_job.rb 0.00 % 26 19 0 19 0.00 100.00 % 0 0 0
app/jobs/cleanup_stuck_scraping_attempts_job.rb 0.00 % 105 66 0 66 0.00 100.00 % 0 0 0
app/jobs/compute_fit_assessment_job.rb 0.00 % 18 9 0 9 0.00 100.00 % 0 0 0
app/jobs/generate_interview_prep_pack_job.rb 0.00 % 43 31 0 31 0.00 100.00 % 0 0 0
app/jobs/generate_round_prep_job.rb 0.00 % 58 36 0 36 0.00 100.00 % 0 0 0
app/jobs/gmail_sync_all_users_job.rb 0.00 % 26 13 0 13 0.00 100.00 % 0 0 0
app/jobs/gmail_sync_job.rb 0.00 % 82 45 0 45 0.00 100.00 % 0 0 0
app/jobs/process_opportunity_email_job.rb 0.00 % 51 21 0 21 0.00 100.00 % 0 0 0
app/jobs/process_signal_extraction_job.rb 0.00 % 192 126 0 126 0.00 100.00 % 0 0 0
app/jobs/purge_deleted_interview_applications_job.rb 0.00 % 17 9 0 9 0.00 100.00 % 0 0 0
app/jobs/recompute_fit_assessments_for_job_listing_job.rb 0.00 % 27 16 0 16 0.00 100.00 % 0 0 0
app/jobs/recompute_fit_assessments_for_user_job.rb 0.00 % 25 16 0 16 0.00 100.00 % 0 0 0
app/jobs/refresh_oauth_tokens_job.rb 0.00 % 64 35 0 35 0.00 100.00 % 0 0 0
app/jobs/scrape_job_listing_job.rb 0.00 % 228 156 0 156 0.00 100.00 % 0 0 0

Libraries ( 47.66% covered at 3.61 hits/line )

18 files in total.
875 relevant lines, 417 lines covered and 458 lines missed. ( 47.66% )
260 total branches, 26 branches covered and 234 branches missed. ( 10.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/lib/exception_notifier.rb 0.00 % 173 103 0 103 0.00 100.00 % 0 0 0
gems/admin_suite/lib/admin/base/action_executor.rb 30.68 % 155 88 27 61 0.31 0.00 % 40 0 40
gems/admin_suite/lib/admin/base/action_handler.rb 62.50 % 31 16 10 6 0.63 100.00 % 0 0 0
gems/admin_suite/lib/admin/base/filter_builder.rb 20.29 % 121 69 14 55 0.20 0.00 % 46 0 46
gems/admin_suite/lib/admin/base/resource.rb 48.59 % 541 177 86 91 1.39 0.00 % 54 0 54
gems/admin_suite/lib/admin_suite.rb 95.83 % 52 24 23 1 22.96 50.00 % 2 1 1
gems/admin_suite/lib/admin_suite/configuration.rb 100.00 % 42 20 20 0 1.00 100.00 % 0 0 0
gems/admin_suite/lib/admin_suite/engine.rb 70.37 % 61 27 19 8 0.70 42.86 % 14 6 8
gems/admin_suite/lib/admin_suite/markdown_renderer.rb 92.00 % 115 50 46 4 9.90 75.00 % 8 6 2
gems/admin_suite/lib/admin_suite/portal_definition.rb 97.14 % 64 35 34 1 22.51 50.00 % 12 6 6
gems/admin_suite/lib/admin_suite/portal_registry.rb 81.82 % 32 11 9 2 26.09 100.00 % 0 0 0
gems/admin_suite/lib/admin_suite/theme_palette.rb 100.00 % 36 12 12 0 13.83 50.00 % 4 2 2
gems/admin_suite/lib/admin_suite/ui/dashboard_definition.rb 97.30 % 69 37 36 1 10.49 35.71 % 14 5 9
gems/admin_suite/lib/admin_suite/ui/field_renderer_registry.rb 53.33 % 119 60 32 28 1.27 0.00 % 4 0 4
gems/admin_suite/lib/admin_suite/ui/form_field_renderer.rb 25.00 % 48 20 5 15 0.25 0.00 % 16 0 16
gems/admin_suite/lib/admin_suite/ui/show_formatter_registry.rb 38.10 % 118 63 24 39 0.70 0.00 % 14 0 14
gems/admin_suite/lib/admin_suite/ui/show_value_formatter.rb 16.67 % 70 30 5 25 0.17 0.00 % 30 0 30
lib/omniauth/strategies/techwright.rb 45.45 % 127 33 15 18 0.45 0.00 % 2 0 2

Ungrouped ( 0.76% covered at 0.01 hits/line )

272 files in total.
22111 relevant lines, 168 lines covered and 21943 lines missed. ( 0.76% )
46 total branches, 1 branches covered and 45 branches missed. ( 2.17% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line Branch Coverage Branches Covered branches Missed branches
app/admin/actions/user_grant_billing_admin_access_action.rb 0.00 % 12 10 0 10 0.00 100.00 % 0 0 0
app/admin/actions/user_revoke_billing_admin_access_action.rb 0.00 % 14 10 0 10 0.00 100.00 % 0 0 0
app/admin/base/export_builder.rb 0.00 % 176 116 0 116 0.00 100.00 % 0 0 0
app/admin/base/field_registry.rb 0.00 % 143 84 0 84 0.00 100.00 % 0 0 0
app/admin/base/navigation.rb 0.00 % 163 127 0 127 0.00 100.00 % 0 0 0
app/admin/base/portal.rb 0.00 % 143 60 0 60 0.00 100.00 % 0 0 0
app/admin/base/stats_calculator.rb 0.00 % 100 65 0 65 0.00 100.00 % 0 0 0
app/admin/portals/ai_portal.rb 0.00 % 43 30 0 30 0.00 100.00 % 0 0 0
app/admin/portals/ops_portal.rb 0.00 % 55 39 0 39 0.00 100.00 % 0 0 0
app/admin/resources/assistant_event_resource.rb 0.00 % 69 57 0 57 0.00 100.00 % 0 0 0
app/admin/resources/assistant_memory_proposal_resource.rb 0.00 % 70 58 0 58 0.00 100.00 % 0 0 0
app/admin/resources/assistant_thread_resource.rb 0.00 % 80 67 0 67 0.00 100.00 % 0 0 0
app/admin/resources/assistant_thread_summary_resource.rb 0.00 % 53 41 0 41 0.00 100.00 % 0 0 0
app/admin/resources/assistant_tool_execution_resource.rb 0.00 % 97 83 0 83 0.00 100.00 % 0 0 0
app/admin/resources/assistant_tool_resource.rb 0.00 % 107 90 0 90 0.00 100.00 % 0 0 0
app/admin/resources/assistant_turn_resource.rb 0.00 % 69 57 0 57 0.00 100.00 % 0 0 0
app/admin/resources/assistant_user_memory_resource.rb 0.00 % 71 58 0 58 0.00 100.00 % 0 0 0
app/admin/resources/billing_feature_resource.rb 0.00 % 43 35 0 35 0.00 100.00 % 0 0 0
app/admin/resources/billing_plan_entitlement_resource.rb 0.00 % 40 34 0 34 0.00 100.00 % 0 0 0
app/admin/resources/billing_plan_resource.rb 0.00 % 99 86 0 86 0.00 100.00 % 0 0 0
app/admin/resources/billing_provider_mapping_resource.rb 0.00 % 44 36 0 36 0.00 100.00 % 0 0 0
app/admin/resources/billing_subscription_resource.rb 0.00 % 40 33 0 33 0.00 100.00 % 0 0 0
app/admin/resources/billing_webhook_event_resource.rb 0.00 % 60 51 0 51 0.00 100.00 % 0 0 0
app/admin/resources/blog_post_resource.rb 0.00 % 102 85 0 85 0.00 100.00 % 0 0 0
app/admin/resources/category_resource.rb 0.00 % 60 45 0 45 0.00 100.00 % 0 0 0
app/admin/resources/company_feedback_resource.rb 0.00 % 44 33 0 33 0.00 100.00 % 0 0 0
app/admin/resources/company_resource.rb 0.00 % 81 67 0 67 0.00 100.00 % 0 0 0
app/admin/resources/connected_account_resource.rb 0.00 % 85 72 0 72 0.00 100.00 % 0 0 0
app/admin/resources/email_pipeline_event_resource.rb 0.00 % 70 61 0 61 0.00 100.00 % 0 0 0
app/admin/resources/email_pipeline_run_resource.rb 0.00 % 66 57 0 57 0.00 100.00 % 0 0 0
app/admin/resources/email_sender_resource.rb 0.00 % 99 84 0 84 0.00 100.00 % 0 0 0
app/admin/resources/html_scraping_log_resource.rb 0.00 % 88 76 0 76 0.00 100.00 % 0 0 0
app/admin/resources/interview_application_resource.rb 0.00 % 95 83 0 83 0.00 100.00 % 0 0 0
app/admin/resources/interview_round_resource.rb 0.00 % 60 49 0 49 0.00 100.00 % 0 0 0
app/admin/resources/interview_round_type_resource.rb 0.00 % 74 59 0 59 0.00 100.00 % 0 0 0
app/admin/resources/job_listing_resource.rb 0.00 % 126 109 0 109 0.00 100.00 % 0 0 0
app/admin/resources/job_role_resource.rb 0.00 % 65 52 0 52 0.00 100.00 % 0 0 0
app/admin/resources/llm_api_log_resource.rb 0.00 % 87 75 0 75 0.00 100.00 % 0 0 0
app/admin/resources/llm_prompt_resource.rb 0.00 % 102 86 0 86 0.00 100.00 % 0 0 0
app/admin/resources/llm_provider_config_resource.rb 0.00 % 107 88 0 88 0.00 100.00 % 0 0 0
app/admin/resources/scraping_attempt_resource.rb 0.00 % 109 96 0 96 0.00 100.00 % 0 0 0
app/admin/resources/scraping_event_resource.rb 0.00 % 72 60 0 60 0.00 100.00 % 0 0 0
app/admin/resources/setting_resource.rb 0.00 % 67 53 0 53 0.00 100.00 % 0 0 0
app/admin/resources/skill_tag_resource.rb 0.00 % 60 46 0 46 0.00 100.00 % 0 0 0
app/admin/resources/support_ticket_resource.rb 0.00 % 90 75 0 75 0.00 100.00 % 0 0 0
app/admin/resources/synced_email_resource.rb 0.00 % 146 128 0 128 0.00 100.00 % 0 0 0
app/admin/resources/user_resource.rb 0.00 % 111 98 0 98 0.00 100.00 % 0 0 0
app/admin_suite/portals/ai.rb 41.07 % 99 56 23 33 0.41 0.00 % 10 0 10
app/admin_suite/portals/assistant.rb 43.55 % 116 62 27 35 0.44 10.00 % 10 1 9
app/admin_suite/portals/email.rb 45.65 % 81 46 21 25 0.46 0.00 % 10 0 10
app/admin_suite/portals/ops.rb 42.62 % 104 61 26 35 0.43 0.00 % 8 0 8
app/admin_suite/portals/payments.rb 80.00 % 31 20 16 4 0.80 100.00 % 0 0 0
app/constraints/developer_authenticated_constraint.rb 40.00 % 24 5 2 3 0.40 0.00 % 2 0 2
app/domains/assistant.rb 100.00 % 8 1 1 0 1.00 100.00 % 0 0 0
app/domains/assistant/contracts/provider_result_contracts.rb 0.00 % 32 24 0 24 0.00 100.00 % 0 0 0
app/domains/assistant/contracts/tool_call_contract.rb 0.00 % 29 16 0 16 0.00 100.00 % 0 0 0
app/domains/assistant/contracts/tool_result_contract.rb 0.00 % 24 17 0 17 0.00 100.00 % 0 0 0
app/domains/assistant/models/chat_message.rb 0.00 % 22 14 0 14 0.00 100.00 % 0 0 0
app/domains/assistant/models/chat_thread.rb 66.67 % 60 18 12 6 0.67 0.00 % 6 0 6
app/domains/assistant/models/has_uuid.rb 83.33 % 29 12 10 2 1.00 100.00 % 0 0 0
app/domains/assistant/models/memory/memory_proposal.rb 0.00 % 20 14 0 14 0.00 100.00 % 0 0 0
app/domains/assistant/models/memory/thread_summary.rb 0.00 % 15 11 0 11 0.00 100.00 % 0 0 0
app/domains/assistant/models/memory/user_memory.rb 0.00 % 15 10 0 10 0.00 100.00 % 0 0 0
app/domains/assistant/models/ops/event.rb 0.00 % 17 11 0 11 0.00 100.00 % 0 0 0
app/domains/assistant/models/tool.rb 0.00 % 19 13 0 13 0.00 100.00 % 0 0 0
app/domains/assistant/models/tool_execution.rb 100.00 % 31 13 13 0 1.00 100.00 % 0 0 0
app/domains/assistant/models/turn.rb 0.00 % 35 24 0 24 0.00 100.00 % 0 0 0
app/domains/assistant/policies/tool_policy.rb 0.00 % 23 14 0 14 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/components/llm_responder.rb 0.00 % 362 279 0 279 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/components/prompt_builder.rb 0.00 % 71 52 0 52 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/components/tool_followup_responder.rb 0.00 % 280 218 0 218 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/components/tool_proposal_recorder.rb 0.00 % 54 44 0 44 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/orchestrator.rb 0.00 % 68 42 0 42 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/tool_result_message_persister.rb 0.00 % 63 46 0 46 0.00 100.00 % 0 0 0
app/domains/assistant/services/chat/turn_runner.rb 0.00 % 166 120 0 120 0.00 100.00 % 0 0 0
app/domains/assistant/services/context/builder.rb 0.00 % 234 178 0 178 0.00 100.00 % 0 0 0
app/domains/assistant/services/memory/memory_proposer.rb 0.00 % 113 89 0 89 0.00 100.00 % 0 0 0
app/domains/assistant/services/memory/thread_summarizer.rb 0.00 % 96 71 0 71 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/anthropic/message_builder.rb 0.00 % 145 113 0 113 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/anthropic/parser.rb 0.00 % 22 17 0 17 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/openai/message_builder.rb 0.00 % 86 68 0 68 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/openai/parser.rb 0.00 % 21 16 0 16 0.00 100.00 % 0 0 0
app/domains/assistant/services/providers/provider_router.rb 0.00 % 93 71 0 71 0.00 100.00 % 0 0 0
app/domains/assistant/services/tools/arg_schema_validator.rb 0.00 % 75 52 0 52 0.00 100.00 % 0 0 0
app/domains/assistant/services/tools/runner.rb 0.00 % 120 87 0 87 0.00 100.00 % 0 0 0
app/domains/assistant/services/tools/tool_schema_adapter.rb 0.00 % 120 81 0 81 0.00 100.00 % 0 0 0
app/domains/assistant/tools/add_note_to_application_tool.rb 0.00 % 42 34 0 34 0.00 100.00 % 0 0 0
app/domains/assistant/tools/add_target_company_tool.rb 0.00 % 92 70 0 70 0.00 100.00 % 0 0 0
app/domains/assistant/tools/add_target_domain_tool.rb 0.00 % 92 70 0 70 0.00 100.00 % 0 0 0
app/domains/assistant/tools/add_target_job_role_tool.rb 0.00 % 92 70 0 70 0.00 100.00 % 0 0 0
app/domains/assistant/tools/base_tool.rb 0.00 % 15 11 0 11 0.00 100.00 % 0 0 0
app/domains/assistant/tools/confirm_user_memory_tool.rb 0.00 % 54 42 0 42 0.00 100.00 % 0 0 0
app/domains/assistant/tools/create_interview_round_tool.rb 0.00 % 74 58 0 58 0.00 100.00 % 0 0 0
app/domains/assistant/tools/generate_interview_prep_tool.rb 0.00 % 114 91 0 91 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_interview_application_tool.rb 0.00 % 58 51 0 51 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_interview_feedback_tool.rb 0.00 % 39 33 0 33 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_interview_prep_tool.rb 0.00 % 109 90 0 90 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_next_interview_tool.rb 0.00 % 44 38 0 38 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_profile_summary_tool.rb 0.00 % 105 91 0 91 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_skill_details_tool.rb 0.00 % 95 81 0 81 0.00 100.00 % 0 0 0
app/domains/assistant/tools/get_work_experience_tool.rb 0.00 % 64 53 0 53 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_interview_applications_tool.rb 0.00 % 71 54 0 54 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_skills_tool.rb 0.00 % 71 57 0 57 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_target_companies_tool.rb 0.00 % 35 30 0 30 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_target_domains_tool.rb 0.00 % 35 30 0 30 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_target_job_roles_tool.rb 0.00 % 35 30 0 30 0.00 100.00 % 0 0 0
app/domains/assistant/tools/list_work_history_tool.rb 0.00 % 61 49 0 49 0.00 100.00 % 0 0 0
app/domains/assistant/tools/remove_target_company_tool.rb 0.00 % 81 61 0 61 0.00 100.00 % 0 0 0
app/domains/assistant/tools/remove_target_domain_tool.rb 0.00 % 81 61 0 61 0.00 100.00 % 0 0 0
app/domains/assistant/tools/remove_target_job_role_tool.rb 0.00 % 79 60 0 60 0.00 100.00 % 0 0 0
app/domains/assistant/tools/update_profile_tool.rb 0.00 % 139 106 0 106 0.00 100.00 % 0 0 0
app/domains/assistant/tools/upsert_interview_feedback_tool.rb 0.00 % 42 33 0 33 0.00 100.00 % 0 0 0
app/domains/signals/contracts/validators/json_schema_validator.rb 0.00 % 62 39 0 39 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/decision_input_builder.rb 0.00 % 219 194 0 194 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/dispatcher.rb 0.00 % 118 104 0 104 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/attach_job_listing_to_opportunity.rb 0.00 % 45 36 0 36 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/base_handler.rb 0.00 % 40 33 0 33 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/create_company_feedback.rb 0.00 % 46 39 0 39 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/create_interview_feedback.rb 0.00 % 37 33 0 33 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/create_opportunity.rb 0.00 % 36 31 0 31 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/create_round.rb 0.00 % 44 37 0 37 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/enqueue_scrape_job_listing.rb 0.00 % 43 35 0 35 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/set_application_status.rb 0.00 % 19 17 0 17 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/set_pipeline_stage.rb 0.00 % 19 17 0 17 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/set_round_result.rb 0.00 % 34 29 0 29 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/update_round.rb 0.00 % 35 30 0 30 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/handlers/upsert_job_listing_from_url.rb 0.00 % 53 43 0 43 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution/precondition_evaluator.rb 0.00 % 101 78 0 78 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/execution_runner.rb 0.00 % 186 163 0 163 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/planner.rb 0.00 % 65 48 0 48 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/base_rule.rb 0.00 % 45 35 0 35 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/opportunity_rule.rb 0.00 % 130 113 0 113 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/round_feedback_rule.rb 0.00 % 75 65 0 65 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/scheduling_rule.rb 0.00 % 51 45 0 45 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/rules/status_update_rule.rb 0.00 % 102 96 0 96 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/semantic_validator.rb 0.00 % 79 61 0 61 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/shadow_runner.rb 0.00 % 150 126 0 126 0.00 100.00 % 0 0 0
app/domains/signals/services/decisioning/step_factory.rb 0.00 % 123 109 0 109 0.00 100.00 % 0 0 0
app/domains/signals/services/facts/canonical_email_event_builder.rb 0.00 % 92 76 0 76 0.00 100.00 % 0 0 0
app/domains/signals/services/facts/email_facts_extractor.rb 0.00 % 158 132 0 132 0.00 100.00 % 0 0 0
app/domains/signals/services/observability/email_pipeline_recorder.rb 0.00 % 166 135 0 135 0.00 100.00 % 0 0 0
app/services/ai/api_logger_service.rb 0.00 % 431 244 0 244 0.00 100.00 % 0 0 0
app/services/ai/error_reporter.rb 0.00 % 37 20 0 20 0.00 100.00 % 0 0 0
app/services/ai/prompt_builder_service.rb 0.00 % 51 27 0 27 0.00 100.00 % 0 0 0
app/services/ai/provider_runner_service.rb 0.00 % 240 169 0 169 0.00 100.00 % 0 0 0
app/services/ai/response_parser_service.rb 0.00 % 42 22 0 22 0.00 100.00 % 0 0 0
app/services/api_fetchers/base_fetcher.rb 0.00 % 116 70 0 70 0.00 100.00 % 0 0 0
app/services/api_fetchers/greenhouse_fetcher.rb 0.00 % 181 115 0 115 0.00 100.00 % 0 0 0
app/services/api_fetchers/lever_fetcher.rb 0.00 % 198 131 0 131 0.00 100.00 % 0 0 0
app/services/application_service.rb 0.00 % 121 45 0 45 0.00 100.00 % 0 0 0
app/services/application_timeline_service.rb 0.00 % 206 158 0 158 0.00 100.00 % 0 0 0
app/services/billing/admin_access_service.rb 0.00 % 93 61 0 61 0.00 100.00 % 0 0 0
app/services/billing/catalog.rb 0.00 % 38 21 0 21 0.00 100.00 % 0 0 0
app/services/billing/debug_snapshot_service.rb 0.00 % 171 124 0 124 0.00 100.00 % 0 0 0
app/services/billing/entitlements.rb 0.00 % 319 188 0 188 0.00 100.00 % 0 0 0
app/services/billing/plan_switcher.rb 0.00 % 152 97 0 97 0.00 100.00 % 0 0 0
app/services/billing/providers/lemon_squeezy.rb 0.00 % 231 166 0 166 0.00 100.00 % 0 0 0
app/services/billing/seed_catalog_service.rb 0.00 % 232 198 0 198 0.00 100.00 % 0 0 0
app/services/billing/trial_unlock_service.rb 0.00 % 95 60 0 60 0.00 100.00 % 0 0 0
app/services/billing/webhooks/lemon_squeezy_processor.rb 0.00 % 478 332 0 332 0.00 100.00 % 0 0 0
app/services/billing/webhooks/processor.rb 0.00 % 29 19 0 19 0.00 100.00 % 0 0 0
app/services/cloudflare_turnstile_service.rb 0.00 % 74 42 0 42 0.00 100.00 % 0 0 0
app/services/compute_fit_assessment_service.rb 0.00 % 177 135 0 135 0.00 100.00 % 0 0 0
app/services/create_job_listing_from_url_service.rb 0.00 % 47 23 0 23 0.00 100.00 % 0 0 0
app/services/dedup/find_category_duplicates_service.rb 0.00 % 37 23 0 23 0.00 100.00 % 0 0 0
app/services/dedup/find_company_duplicates_service.rb 0.00 % 78 55 0 55 0.00 100.00 % 0 0 0
app/services/dedup/find_job_role_duplicates_service.rb 0.00 % 64 43 0 43 0.00 100.00 % 0 0 0
app/services/dedup/find_skill_tag_duplicates_service.rb 0.00 % 64 43 0 43 0.00 100.00 % 0 0 0
app/services/dedup/merge_category_service.rb 0.00 % 61 38 0 38 0.00 100.00 % 0 0 0
app/services/dedup/merge_company_service.rb 0.00 % 98 66 0 66 0.00 100.00 % 0 0 0
app/services/dedup/merge_job_role_service.rb 0.00 % 89 61 0 61 0.00 100.00 % 0 0 0
app/services/dedup/merge_skill_tag_service.rb 0.00 % 85 59 0 59 0.00 100.00 % 0 0 0
app/services/feedback_analysis_service.rb 0.00 % 74 43 0 43 0.00 100.00 % 0 0 0
app/services/gmail/client_service.rb 0.00 % 87 45 0 45 0.00 100.00 % 0 0 0
app/services/gmail/company_matcher_service.rb 0.00 % 209 107 0 107 0.00 100.00 % 0 0 0
app/services/gmail/email_processor_service.rb 0.00 % 631 437 0 437 0.00 100.00 % 0 0 0
app/services/gmail/errors.rb 0.00 % 12 9 0 9 0.00 100.00 % 0 0 0
app/services/gmail/opportunity_detector_service.rb 0.00 % 304 196 0 196 0.00 100.00 % 0 0 0
app/services/gmail/sync_service.rb 0.00 % 788 514 0 514 0.00 100.00 % 0 0 0
app/services/interview_prep/base_generator_service.rb 0.00 % 169 133 0 133 0.00 100.00 % 0 0 0
app/services/interview_prep/generate_focus_areas_service.rb 0.00 % 31 24 0 24 0.00 100.00 % 0 0 0
app/services/interview_prep/generate_match_analysis_service.rb 0.00 % 27 21 0 21 0.00 100.00 % 0 0 0
app/services/interview_prep/generate_question_framing_service.rb 0.00 % 34 26 0 26 0.00 100.00 % 0 0 0
app/services/interview_prep/generate_strength_positioning_service.rb 0.00 % 33 25 0 25 0.00 100.00 % 0 0 0
app/services/interview_prep/inputs_builder_service.rb 0.00 % 133 102 0 102 0.00 100.00 % 0 0 0
app/services/interview_round_prep/company_patterns_service.rb 0.00 % 196 118 0 118 0.00 100.00 % 0 0 0
app/services/interview_round_prep/generate_service.rb 0.00 % 180 123 0 123 0.00 100.00 % 0 0 0
app/services/interview_round_prep/historical_analyzer_service.rb 0.00 % 187 114 0 114 0.00 100.00 % 0 0 0
app/services/interview_round_prep/inputs_builder_service.rb 0.00 % 170 113 0 113 0.00 100.00 % 0 0 0
app/services/job_listing_scraper_service.rb 0.00 % 112 75 0 75 0.00 100.00 % 0 0 0
app/services/job_listings/upsert_from_url_service.rb 0.00 % 72 49 0 49 0.00 100.00 % 0 0 0
app/services/labels/dedupe_service.rb 0.00 % 141 89 0 89 0.00 100.00 % 0 0 0
app/services/llm_providers/anthropic_provider.rb 0.00 % 610 432 0 432 0.00 100.00 % 0 0 0
app/services/llm_providers/base_provider.rb 0.00 % 238 113 0 113 0.00 100.00 % 0 0 0
app/services/llm_providers/ollama_provider.rb 0.00 % 141 109 0 109 0.00 100.00 % 0 0 0
app/services/llm_providers/openai_provider.rb 0.00 % 463 317 0 317 0.00 100.00 % 0 0 0
app/services/llm_providers/provider_config_helper.rb 0.00 % 38 15 0 15 0.00 100.00 % 0 0 0
app/services/markdown_renderer.rb 0.00 % 150 90 0 90 0.00 100.00 % 0 0 0
app/services/oauth_authentication_service.rb 0.00 % 99 51 0 51 0.00 100.00 % 0 0 0
app/services/opportunities/create_application_service.rb 0.00 % 263 142 0 142 0.00 100.00 % 0 0 0
app/services/opportunities/extraction_service.rb 0.00 % 231 143 0 143 0.00 100.00 % 0 0 0
app/services/profile_insights_service.rb 0.00 % 194 133 0 133 0.00 100.00 % 0 0 0
app/services/quick_apply_from_url_service.rb 0.00 % 343 174 0 174 0.00 100.00 % 0 0 0
app/services/resumes/ai_skill_extractor_service.rb 0.00 % 501 320 0 320 0.00 100.00 % 0 0 0
app/services/resumes/analysis_service.rb 0.00 % 360 208 0 208 0.00 100.00 % 0 0 0
app/services/resumes/skill_aggregation_service.rb 0.00 % 217 129 0 129 0.00 100.00 % 0 0 0
app/services/resumes/text_extractor_service.rb 0.00 % 186 113 0 113 0.00 100.00 % 0 0 0
app/services/resumes/work_history_aggregation_service.rb 0.00 % 107 80 0 80 0.00 100.00 % 0 0 0
app/services/scraping/ai_job_extractor_service.rb 0.00 % 245 184 0 184 0.00 100.00 % 0 0 0
app/services/scraping/ai_job_post_processor_service.rb 0.00 % 140 110 0 110 0.00 100.00 % 0 0 0
app/services/scraping/anthropic_rate_limiter_service.rb 0.00 % 112 52 0 52 0.00 100.00 % 0 0 0
app/services/scraping/concerns/loggable.rb 0.00 % 68 33 0 33 0.00 100.00 % 0 0 0
app/services/scraping/event_recorder_service.rb 0.00 % 264 148 0 148 0.00 100.00 % 0 0 0
app/services/scraping/failure_classifier_service.rb 0.00 % 61 32 0 32 0.00 100.00 % 0 0 0
app/services/scraping/html_cleaners/ashby_cleaner.rb 0.00 % 62 40 0 40 0.00 100.00 % 0 0 0
app/services/scraping/html_cleaners/base_cleaner.rb 0.00 % 124 84 0 84 0.00 100.00 % 0 0 0
app/services/scraping/html_cleaners/cleaner_factory.rb 0.00 % 44 23 0 23 0.00 100.00 % 0 0 0
app/services/scraping/html_fetcher_service.rb 0.00 % 188 123 0 123 0.00 100.00 % 0 0 0
app/services/scraping/html_scraping_service.rb 0.00 % 567 392 0 392 0.00 100.00 % 0 0 0
app/services/scraping/job_board_detector_service.rb 0.00 % 229 132 0 132 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/ashby_extractor.rb 0.00 % 148 85 0 85 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/bamboo_hr_extractor.rb 0.00 % 32 27 0 27 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/base_extractor.rb 0.00 % 163 126 0 126 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/extractor_factory.rb 0.00 % 31 28 0 28 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/greenhouse_extractor.rb 0.00 % 64 56 0 56 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/icims_extractor.rb 0.00 % 33 28 0 28 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/jobvite_extractor.rb 0.00 % 39 33 0 33 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/lever_extractor.rb 0.00 % 39 33 0 33 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/smart_recruiters_extractor.rb 0.00 % 40 34 0 34 0.00 100.00 % 0 0 0
app/services/scraping/job_boards/workable_extractor.rb 0.00 % 40 34 0 34 0.00 100.00 % 0 0 0
app/services/scraping/nokogiri_html_cleaner_service.rb 0.00 % 177 69 0 69 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/context.rb 0.00 % 34 26 0 26 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/runner.rb 0.00 % 48 40 0 40 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/ai_extract.rb 0.00 % 108 91 0 91 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/api_extract.rb 0.00 % 130 106 0 106 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/base_step.rb 0.00 % 27 21 0 21 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/detect_job_board.rb 0.00 % 31 27 0 27 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/fetch_html.rb 0.00 % 38 32 0 32 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/handle_limited_sources.rb 0.00 % 182 125 0 125 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/nokogiri_scrape.rb 0.00 % 39 36 0 36 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/rendered_fallback.rb 0.00 % 89 80 0 80 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/resolve_embedded_job_board.rb 0.00 % 154 123 0 123 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/steps/selectors_extract.rb 0.00 % 55 47 0 47 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/support/attempt_lifecycle.rb 0.00 % 146 112 0 112 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/support/entity_resolver.rb 0.00 % 235 185 0 185 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/support/job_listing_updater.rb 0.00 % 118 96 0 96 0.00 100.00 % 0 0 0
app/services/scraping/orchestration/support/observability.rb 0.00 % 103 86 0 86 0.00 100.00 % 0 0 0
app/services/scraping/orchestrator_service.rb 0.00 % 46 30 0 30 0.00 100.00 % 0 0 0
app/services/scraping/rate_limiter_service.rb 0.00 % 101 50 0 50 0.00 100.00 % 0 0 0
app/services/scraping/rendered_html_fetcher_service.rb 0.00 % 319 234 0 234 0.00 100.00 % 0 0 0
app/services/scraping/retry_service.rb 0.00 % 255 164 0 164 0.00 100.00 % 0 0 0
app/services/scraping/robots_txt_checker_service.rb 0.00 % 98 57 0 57 0.00 100.00 % 0 0 0
app/services/scraping/salary_range_validator.rb 0.00 % 107 67 0 67 0.00 100.00 % 0 0 0
app/services/signals/action_applier.rb 0.00 % 134 112 0 112 0.00 100.00 % 0 0 0
app/services/signals/action_executor.rb 0.00 % 97 47 0 47 0.00 100.00 % 0 0 0
app/services/signals/actions/base_action.rb 0.00 % 124 55 0 55 0.00 100.00 % 0 0 0
app/services/signals/actions/start_application_action.rb 0.00 % 273 152 0 152 0.00 100.00 % 0 0 0
app/services/signals/application_status_processor.rb 0.00 % 502 331 0 331 0.00 100.00 % 0 0 0
app/services/signals/company_feedback_processor.rb 0.00 % 161 92 0 92 0.00 100.00 % 0 0 0
app/services/signals/email_state_orchestrator.rb 0.00 % 86 70 0 70 0.00 100.00 % 0 0 0
app/services/signals/extraction_service.rb 0.00 % 488 301 0 301 0.00 100.00 % 0 0 0
app/services/signals/interview_round_processor.rb 0.00 % 439 280 0 280 0.00 100.00 % 0 0 0
app/services/signals/round_feedback_processor.rb 0.00 % 491 329 0 329 0.00 100.00 % 0 0 0
app/services/signals/rules/application_confirmation_rule.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/services/signals/rules/base_rule.rb 0.00 % 54 46 0 46 0.00 100.00 % 0 0 0
app/services/signals/rules/offer_rule.rb 0.00 % 19 15 0 15 0.00 100.00 % 0 0 0
app/services/signals/rules/rejection_rule.rb 0.00 % 20 16 0 16 0.00 100.00 % 0 0 0
app/services/signals/rules/round_feedback_rule.rb 0.00 % 20 16 0 16 0.00 100.00 % 0 0 0
app/services/signals/rules/scheduling_rule.rb 0.00 % 22 17 0 17 0.00 100.00 % 0 0 0
app/services/signals/state_context.rb 0.00 % 21 16 0 16 0.00 100.00 % 0 0 0
app/services/signals/state_transition_planner.rb 0.00 % 40 33 0 33 0.00 100.00 % 0 0 0
gems/admin_suite/config/routes.rb 100.00 % 29 17 17 0 1.00 100.00 % 0 0 0

app/admin/actions/user_grant_billing_admin_access_action.rb

0.0% lines covered

100.0% branches covered

10 relevant lines. 0 lines covered and 10 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Actions
  4. class UserGrantBillingAdminAccessAction < Admin::Base::ActionHandler
  5. def call
  6. Billing::AdminAccessService.new(user: record, actor: current_user).grant!
  7. success("Granted Admin/Developer billing access.")
  8. end
  9. end
  10. end
  11. end

app/admin/actions/user_revoke_billing_admin_access_action.rb

0.0% lines covered

100.0% branches covered

10 relevant lines. 0 lines covered and 10 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Actions
  4. class UserRevokeBillingAdminAccessAction < Admin::Base::ActionHandler
  5. def call
  6. Billing::AdminAccessService.new(user: record, actor: current_user).revoke!
  7. success("Revoked Admin/Developer billing access.")
  8. end
  9. end
  10. end
  11. end

app/admin/base/export_builder.rb

0.0% lines covered

100.0% branches covered

116 relevant lines. 0 lines covered and 116 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "csv"
  3. module Admin
  4. module Base
  5. # Builds export data for admin resources
  6. #
  7. # Generates JSON or CSV exports based on resource configuration
  8. # and the columns defined in the index configuration.
  9. #
  10. # @example
  11. # export_builder = Admin::Base::ExportBuilder.new(CompanyResource, companies)
  12. # json_data = export_builder.to_json
  13. # csv_data = export_builder.to_csv
  14. class ExportBuilder
  15. attr_reader :resource_class, :records
  16. # Initializes the export builder
  17. #
  18. # @param resource_class [Class] The resource class
  19. # @param records [ActiveRecord::Relation, Array] Records to export
  20. def initialize(resource_class, records)
  21. @resource_class = resource_class
  22. @records = records
  23. end
  24. # Exports records to JSON
  25. #
  26. # @param options [Hash] Export options
  27. # @option options [Boolean] :pretty Pretty print JSON
  28. # @return [String] JSON string
  29. def to_json(options = {})
  30. data = records.map { |record| record_to_hash(record) }
  31. if options[:pretty]
  32. JSON.pretty_generate(export_wrapper(data))
  33. else
  34. export_wrapper(data).to_json
  35. end
  36. end
  37. # Exports records to CSV
  38. #
  39. # @return [String] CSV string
  40. def to_csv
  41. return "" if records.empty?
  42. headers = export_columns.map { |col| col[:header] }
  43. CSV.generate do |csv|
  44. csv << headers
  45. records.each do |record|
  46. csv << export_columns.map { |col| column_value(record, col) }
  47. end
  48. end
  49. end
  50. # Returns the filename for export
  51. #
  52. # @param format [Symbol] Export format (:json, :csv)
  53. # @return [String]
  54. def filename(format)
  55. timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
  56. "#{resource_class.resource_name_plural}_#{timestamp}.#{format}"
  57. end
  58. # Returns content type for export
  59. #
  60. # @param format [Symbol] Export format
  61. # @return [String]
  62. def content_type(format)
  63. case format.to_sym
  64. when :json
  65. "application/json"
  66. when :csv
  67. "text/csv"
  68. else
  69. "application/octet-stream"
  70. end
  71. end
  72. private
  73. def index_config
  74. @resource_class.index_config
  75. end
  76. def model_class
  77. @resource_class.model_class
  78. end
  79. def export_wrapper(data)
  80. {
  81. resource: resource_class.resource_name,
  82. exported_at: Time.current.iso8601,
  83. count: data.length,
  84. records: data
  85. }
  86. end
  87. def export_columns
  88. return default_columns unless index_config
  89. columns = index_config.columns_list.map do |col|
  90. {
  91. name: col.name,
  92. header: col.header,
  93. content: col.content
  94. }
  95. end
  96. # Add id and timestamps if not already present
  97. unless columns.any? { |c| c[:name] == :id }
  98. columns.unshift({ name: :id, header: "ID", content: nil })
  99. end
  100. unless columns.any? { |c| c[:name] == :created_at }
  101. columns << { name: :created_at, header: "Created At", content: nil }
  102. end
  103. columns
  104. end
  105. def default_columns
  106. model_class.column_names.map do |col|
  107. { name: col.to_sym, header: col.humanize, content: nil }
  108. end
  109. end
  110. def record_to_hash(record)
  111. hash = {}
  112. export_columns.each do |col|
  113. hash[col[:name]] = column_value(record, col)
  114. end
  115. hash
  116. end
  117. def column_value(record, col)
  118. if col[:content].is_a?(Proc)
  119. value = col[:content].call(record)
  120. elsif col[:content].is_a?(Symbol)
  121. value = record.public_send(col[:content])
  122. elsif record.respond_to?(col[:name])
  123. value = record.public_send(col[:name])
  124. else
  125. value = nil
  126. end
  127. # Serialize complex values for export
  128. serialize_value(value)
  129. end
  130. def serialize_value(value)
  131. case value
  132. when ActiveRecord::Base
  133. value.id
  134. when ActiveRecord::Relation
  135. value.pluck(:id)
  136. when Time, DateTime
  137. value.iso8601
  138. when Date
  139. value.to_s
  140. when Hash, Array
  141. value
  142. else
  143. value.to_s
  144. end
  145. end
  146. end
  147. end
  148. end

app/admin/base/field_registry.rb

0.0% lines covered

100.0% branches covered

84 relevant lines. 0 lines covered and 84 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Base
  4. # Registry for form field types
  5. #
  6. # Maps field types to their corresponding view partials and
  7. # provides helper methods for rendering fields based on their type.
  8. #
  9. # @example
  10. # registry = Admin::Base::FieldRegistry.new
  11. # partial = registry.partial_for(:toggle)
  12. # # => "admin/fields/toggle"
  13. class FieldRegistry
  14. # Default field type to partial mappings
  15. FIELD_TYPES = {
  16. # Basic types
  17. text: { partial: "admin/fields/text", input_type: :text_field },
  18. string: { partial: "admin/fields/text", input_type: :text_field },
  19. email: { partial: "admin/fields/text", input_type: :email_field },
  20. url: { partial: "admin/fields/text", input_type: :url_field },
  21. tel: { partial: "admin/fields/text", input_type: :telephone_field },
  22. password: { partial: "admin/fields/text", input_type: :password_field },
  23. number: { partial: "admin/fields/number", input_type: :number_field },
  24. integer: { partial: "admin/fields/number", input_type: :number_field },
  25. decimal: { partial: "admin/fields/number", input_type: :number_field },
  26. # Text areas
  27. textarea: { partial: "admin/fields/textarea", input_type: :text_area },
  28. text_area: { partial: "admin/fields/textarea", input_type: :text_area },
  29. # Boolean
  30. boolean: { partial: "admin/fields/toggle", input_type: :check_box },
  31. toggle: { partial: "admin/fields/toggle", input_type: nil },
  32. checkbox: { partial: "admin/fields/checkbox", input_type: :check_box },
  33. # Date/Time
  34. date: { partial: "admin/fields/date", input_type: :date_field },
  35. datetime: { partial: "admin/fields/datetime", input_type: :datetime_local_field },
  36. time: { partial: "admin/fields/time", input_type: :time_field },
  37. date_range: { partial: "admin/fields/date_range", input_type: nil },
  38. # Select
  39. select: { partial: "admin/fields/select", input_type: :select },
  40. searchable_select: { partial: "admin/fields/searchable_select", input_type: nil },
  41. collection_select: { partial: "admin/fields/collection_select", input_type: :collection_select },
  42. # Rich content
  43. rich_text: { partial: "admin/fields/rich_text", input_type: :rich_text_area },
  44. trix: { partial: "admin/fields/rich_text", input_type: :rich_text_area },
  45. markdown: { partial: "admin/fields/markdown", input_type: :text_area },
  46. # File
  47. file: { partial: "admin/fields/file", input_type: :file_field },
  48. image: { partial: "admin/fields/file", input_type: :file_field },
  49. # Special
  50. json: { partial: "admin/fields/json", input_type: :text_area },
  51. color: { partial: "admin/fields/color", input_type: :color_field },
  52. hidden: { partial: nil, input_type: :hidden_field },
  53. tag_picker: { partial: "admin/fields/tag_picker", input_type: nil },
  54. # Read-only display
  55. readonly: { partial: "admin/fields/readonly", input_type: nil },
  56. badge: { partial: "admin/fields/badge", input_type: nil }
  57. }.freeze
  58. class << self
  59. # Returns the partial path for a field type
  60. #
  61. # @param type [Symbol] Field type
  62. # @return [String, nil] Partial path or nil
  63. def partial_for(type)
  64. config = FIELD_TYPES[type.to_sym]
  65. config&.dig(:partial)
  66. end
  67. # Returns the input type for a field type
  68. #
  69. # @param type [Symbol] Field type
  70. # @return [Symbol, nil] Form builder input method
  71. def input_type_for(type)
  72. config = FIELD_TYPES[type.to_sym]
  73. config&.dig(:input_type)
  74. end
  75. # Checks if a field type uses a custom partial
  76. #
  77. # @param type [Symbol] Field type
  78. # @return [Boolean]
  79. def custom_partial?(type)
  80. partial_for(type).present?
  81. end
  82. # Returns all registered field types
  83. #
  84. # @return [Array<Symbol>]
  85. def types
  86. FIELD_TYPES.keys
  87. end
  88. # Checks if a field type is valid
  89. #
  90. # @param type [Symbol] Field type
  91. # @return [Boolean]
  92. def valid_type?(type)
  93. FIELD_TYPES.key?(type.to_sym)
  94. end
  95. # Returns options for rendering a field
  96. #
  97. # @param field_def [FieldDefinition] Field definition
  98. # @return [Hash] Options hash for rendering
  99. def options_for(field_def)
  100. {
  101. form: nil, # Set by caller
  102. field: field_def.name,
  103. label: field_def.label,
  104. help: field_def.help,
  105. placeholder: field_def.placeholder,
  106. required: field_def.required,
  107. readonly: field_def.readonly
  108. }.tap do |opts|
  109. # Add type-specific options
  110. case field_def.type
  111. when :select, :collection_select
  112. opts[:collection] = field_def.collection
  113. when :searchable_select
  114. opts[:search_url] = field_def.collection
  115. opts[:create_url] = field_def.create_url
  116. opts[:creatable] = field_def.create_url.present?
  117. when :file, :image
  118. opts[:accept] = field_def.accept
  119. opts[:preview] = field_def.type == :image
  120. when :textarea, :markdown
  121. opts[:rows] = field_def.rows || 6
  122. end
  123. end
  124. end
  125. end
  126. end
  127. end
  128. end

app/admin/base/navigation.rb

0.0% lines covered

100.0% branches covered

127 relevant lines. 0 lines covered and 127 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Base
  4. # Navigation builder for admin portals
  5. #
  6. # Builds navigation structure from portal and resource definitions.
  7. # Used by the sidebar partial to render navigation links.
  8. #
  9. # @example
  10. # navigation = Admin::Base::Navigation.new(current_portal, current_path)
  11. # navigation.sections.each do |section|
  12. # section.items.each do |item|
  13. # # render nav item
  14. # end
  15. # end
  16. class Navigation
  17. attr_reader :portal, :current_path
  18. NavSection = Struct.new(:key, :label, :icon, :items, keyword_init: true)
  19. NavItem = Struct.new(:key, :label, :path, :icon, :badge, :active, keyword_init: true)
  20. # Icon mappings for resources
  21. RESOURCE_ICONS = {
  22. dashboard: :home,
  23. users: :users,
  24. email_senders: :mail,
  25. connected_accounts: :link,
  26. synced_emails: :inbox,
  27. blog_posts: :document_text,
  28. companies: :building_office,
  29. job_roles: :briefcase,
  30. job_listings: :clipboard_list,
  31. categories: :tag,
  32. skill_tags: :hashtag,
  33. scraping_metrics: :chart_bar,
  34. scraping_attempts: :clock,
  35. scraping_events: :bell,
  36. html_scraping_logs: :code,
  37. support_tickets: :ticket,
  38. interview_applications: :document,
  39. settings: :cog,
  40. assistant_threads: :chat_bubble_left_right,
  41. assistant_turns: :arrow_path,
  42. assistant_events: :bolt,
  43. assistant_tools: :wrench,
  44. assistant_tool_executions: :play,
  45. assistant_user_memories: :light_bulb,
  46. assistant_memory_proposals: :clipboard_check,
  47. assistant_thread_summaries: :document_text,
  48. llm_prompts: :command_line,
  49. llm_provider_configs: :cpu_chip,
  50. llm_api_logs: :server
  51. }.freeze
  52. def initialize(portal, current_path)
  53. @portal = portal
  54. @current_path = current_path
  55. end
  56. # Returns navigation sections for the portal
  57. #
  58. # @return [Array<NavSection>]
  59. def sections
  60. return [] unless portal&.sections_list
  61. portal.sections_list.map do |section|
  62. NavSection.new(
  63. key: section.key,
  64. label: section.display_label,
  65. icon: section.section_icon,
  66. items: items_for_section(section)
  67. )
  68. end
  69. end
  70. # Returns the portal switcher data
  71. #
  72. # @return [Array<Hash>]
  73. def portal_switcher
  74. Portal.registered_portals.map do |p|
  75. {
  76. key: p.identifier,
  77. name: p.portal_name,
  78. icon: p.portal_icon,
  79. path: p.portal_path_prefix,
  80. active: portal == p
  81. }
  82. end
  83. end
  84. private
  85. def items_for_section(section)
  86. section.resource_keys.map do |key|
  87. build_nav_item(key)
  88. end.compact
  89. end
  90. def build_nav_item(key)
  91. path = path_for_resource(key)
  92. return nil unless path
  93. NavItem.new(
  94. key: key,
  95. label: label_for_resource(key),
  96. path: path,
  97. icon: RESOURCE_ICONS[key] || :folder,
  98. badge: badge_for_resource(key),
  99. active: current_path&.start_with?(path)
  100. )
  101. end
  102. def path_for_resource(key)
  103. case portal.identifier
  104. when :operations
  105. ops_path_for(key)
  106. when :ai
  107. ai_path_for(key)
  108. else
  109. "/admin/#{key}"
  110. end
  111. end
  112. def ops_path_for(key)
  113. case key
  114. when :dashboard then "/admin"
  115. when :scraping_metrics then "/admin/scraping_metrics"
  116. else "/admin/#{key}"
  117. end
  118. end
  119. def ai_path_for(key)
  120. case key
  121. when :dashboard then "/admin/ai"
  122. when :llm_prompts then "/admin/ai/llm_prompts"
  123. when :llm_api_logs then "/admin/ai/llm_api_logs"
  124. else "/admin/#{key}"
  125. end
  126. end
  127. def label_for_resource(key)
  128. key.to_s.humanize.titleize
  129. end
  130. def badge_for_resource(key)
  131. case key
  132. when :email_senders
  133. count = EmailSender.unassigned.count rescue 0
  134. count.positive? ? count : nil
  135. when :support_tickets
  136. count = SupportTicket.open_tickets.count rescue 0
  137. count.positive? ? count : nil
  138. when :synced_emails
  139. count = SyncedEmail.needs_review.count rescue 0
  140. count.positive? ? count : nil
  141. else
  142. nil
  143. end
  144. end
  145. end
  146. end
  147. end

app/admin/base/portal.rb

0.0% lines covered

100.0% branches covered

60 relevant lines. 0 lines covered and 60 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Base
  4. # Base class for admin portals
  5. #
  6. # A portal is a logical grouping of admin resources with its own
  7. # navigation, dashboard, and access controls.
  8. #
  9. # @example
  10. # class Admin::Portals::OpsPortal < Admin::Base::Portal
  11. # name "Operations"
  12. # icon :building
  13. # path_prefix "/admin/ops"
  14. #
  15. # section :users_email do
  16. # label "Users & Email"
  17. # icon :users
  18. # resources :users, :email_senders, :connected_accounts
  19. # end
  20. # end
  21. class Portal
  22. class << self
  23. attr_reader :portal_name, :portal_icon, :portal_path_prefix, :sections_list
  24. # Sets the portal display name
  25. #
  26. # @param value [String] Portal name
  27. # @return [void]
  28. def name(value)
  29. @portal_name = value
  30. end
  31. # Sets the portal icon
  32. #
  33. # @param value [Symbol] Icon name
  34. # @return [void]
  35. def icon(value)
  36. @portal_icon = value
  37. end
  38. # Sets the path prefix for this portal
  39. #
  40. # @param value [String] Path prefix
  41. # @return [void]
  42. def path_prefix(value)
  43. @portal_path_prefix = value
  44. end
  45. # Defines a section within the portal
  46. #
  47. # @param key [Symbol] Section key
  48. # @yield Block for section configuration
  49. # @return [void]
  50. def section(key, &block)
  51. @sections_list ||= []
  52. section = SectionConfig.new(key)
  53. section.instance_eval(&block) if block_given?
  54. @sections_list << section
  55. end
  56. # Returns the portal identifier
  57. #
  58. # @return [Symbol]
  59. def identifier
  60. portal_name.to_s.parameterize.underscore.to_sym
  61. end
  62. # Returns resources for this portal
  63. #
  64. # @return [Array<Class>]
  65. def resources
  66. Resource.resources_for_portal(identifier)
  67. end
  68. # Returns all registered portals
  69. #
  70. # @return [Array<Class>]
  71. def registered_portals
  72. @registered_portals ||= []
  73. end
  74. # Called when a subclass is created
  75. def inherited(subclass)
  76. super
  77. # Use to_s to get class name to avoid conflict with our custom name(value) method
  78. class_name = subclass.to_s
  79. registered_portals << subclass unless class_name.include?("Base")
  80. end
  81. # Finds a portal by identifier
  82. #
  83. # @param identifier [Symbol] Portal identifier
  84. # @return [Class, nil]
  85. def find(identifier)
  86. registered_portals.find { |p| p.identifier == identifier.to_sym }
  87. end
  88. end
  89. # Section configuration within a portal
  90. class SectionConfig
  91. attr_reader :key, :section_label, :section_icon, :resource_keys
  92. def initialize(key)
  93. @key = key
  94. @resource_keys = []
  95. end
  96. # Sets the section label
  97. #
  98. # @param value [String] Section label
  99. # @return [void]
  100. def label(value)
  101. @section_label = value
  102. end
  103. # Sets the section icon
  104. #
  105. # @param value [Symbol] Icon name
  106. # @return [void]
  107. def icon(value)
  108. @section_icon = value
  109. end
  110. # Adds resources to this section
  111. #
  112. # @param keys [Array<Symbol>] Resource keys
  113. # @return [void]
  114. def resources(*keys)
  115. @resource_keys.concat(keys)
  116. end
  117. # Returns the display label
  118. #
  119. # @return [String]
  120. def display_label
  121. section_label || key.to_s.humanize
  122. end
  123. end
  124. end
  125. end
  126. end

app/admin/base/stats_calculator.rb

0.0% lines covered

100.0% branches covered

65 relevant lines. 0 lines covered and 65 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Base
  4. # Calculates statistics for admin resource index pages
  5. #
  6. # Uses the stat definitions from the resource's index configuration
  7. # to generate a hash of calculated statistics.
  8. #
  9. # @example
  10. # calculator = Admin::Base::StatsCalculator.new(CompanyResource)
  11. # stats = calculator.calculate
  12. # # => { total: 150, with_website: 120, with_job_listings: 45 }
  13. class StatsCalculator
  14. attr_reader :resource_class
  15. # Initializes the stats calculator
  16. #
  17. # @param resource_class [Class] The resource class with stat definitions
  18. def initialize(resource_class)
  19. @resource_class = resource_class
  20. end
  21. # Calculates all defined statistics
  22. #
  23. # @return [Hash] Hash of stat names to calculated values
  24. def calculate
  25. return {} unless index_config
  26. return {} if index_config.stats_list.empty?
  27. stats = {}
  28. index_config.stats_list.each do |stat_def|
  29. stats[stat_def.name] = calculate_stat(stat_def)
  30. end
  31. stats
  32. end
  33. # Returns stat colors for display
  34. #
  35. # @return [Hash] Hash of stat names to color classes
  36. def colors
  37. return {} unless index_config
  38. colors = {}
  39. index_config.stats_list.each do |stat_def|
  40. next unless stat_def.color
  41. colors[stat_def.name] = color_class_for(stat_def.color)
  42. end
  43. colors
  44. end
  45. private
  46. def index_config
  47. @resource_class.index_config
  48. end
  49. def calculate_stat(stat_def)
  50. if stat_def.calculator.is_a?(Proc)
  51. stat_def.calculator.call
  52. elsif stat_def.calculator.is_a?(Symbol)
  53. model_class.public_send(stat_def.calculator)
  54. else
  55. stat_def.calculator
  56. end
  57. rescue StandardError => e
  58. Rails.logger.error "Failed to calculate stat #{stat_def.name}: #{e.message}"
  59. "N/A"
  60. end
  61. def model_class
  62. @resource_class.model_class
  63. end
  64. def color_class_for(color)
  65. case color.to_sym
  66. when :blue
  67. "text-blue-600 dark:text-blue-400"
  68. when :green
  69. "text-green-600 dark:text-green-400"
  70. when :amber, :yellow
  71. "text-amber-600 dark:text-amber-400"
  72. when :red
  73. "text-red-600 dark:text-red-400"
  74. when :purple
  75. "text-purple-600 dark:text-purple-400"
  76. when :slate, :gray
  77. "text-slate-600 dark:text-slate-400"
  78. else
  79. "text-slate-900 dark:text-white"
  80. end
  81. end
  82. end
  83. end
  84. end

app/admin/portals/ai_portal.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Portals
  4. # AI Portal
  5. #
  6. # Contains resources for AI and Assistant operations including:
  7. # - Assistant threads and conversations
  8. # - Tool executions and management
  9. # - User memories and proposals
  10. # - LLM configuration and logs
  11. class AiPortal < Admin::Base::Portal
  12. name "AI"
  13. icon :sparkles
  14. path_prefix "/admin/ai"
  15. section :dashboard do
  16. label "Dashboard"
  17. icon :chart_bar
  18. resources :dashboard
  19. end
  20. section :assistant do
  21. label "Assistant"
  22. icon :chat
  23. resources :assistant_threads, :assistant_turns, :assistant_events,
  24. :assistant_tools, :assistant_tool_executions
  25. end
  26. section :memory do
  27. label "Memory"
  28. icon :brain
  29. resources :assistant_user_memories, :assistant_memory_proposals, :assistant_thread_summaries
  30. end
  31. section :llm do
  32. label "LLM"
  33. icon :cpu
  34. resources :llm_prompts, :llm_provider_configs, :llm_api_logs
  35. end
  36. end
  37. end
  38. end

app/admin/portals/ops_portal.rb

0.0% lines covered

100.0% branches covered

39 relevant lines. 0 lines covered and 39 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Portals
  4. # Operations Portal
  5. #
  6. # Contains resources for day-to-day operations including:
  7. # - User management
  8. # - Email management
  9. # - Content management
  10. # - Scraping operations
  11. # - Support tickets
  12. class OpsPortal < Admin::Base::Portal
  13. name "Operations"
  14. icon :building
  15. path_prefix "/admin/ops"
  16. section :dashboard do
  17. label "Dashboard"
  18. icon :home
  19. resources :dashboard
  20. end
  21. section :users_email do
  22. label "Users & Email"
  23. icon :users
  24. resources :users, :email_senders, :connected_accounts, :synced_emails
  25. end
  26. section :content do
  27. label "Content"
  28. icon :document
  29. resources :blog_posts, :companies, :job_roles, :job_listings, :categories, :skill_tags
  30. end
  31. section :scraping do
  32. label "Scraping"
  33. icon :code
  34. resources :scraping_metrics, :scraping_attempts, :scraping_events, :html_scraping_logs
  35. end
  36. section :support do
  37. label "Support"
  38. icon :chat
  39. resources :support_tickets, :interview_applications
  40. end
  41. section :system do
  42. label "System"
  43. icon :cog
  44. resources :settings
  45. end
  46. end
  47. end
  48. end

app/admin/resources/assistant_event_resource.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Assistant Event admin management
  5. #
  6. # Provides read-only access to assistant events for debugging.
  7. class AssistantEventResource < Admin::Base::Resource
  8. model ::Assistant::Ops::Event
  9. portal :assistant
  10. section :events
  11. index do
  12. sortable :created_at, default: :created_at
  13. paginate 50
  14. stats do
  15. stat :total, -> { ::Assistant::Ops::Event.count }
  16. stat :info, -> { ::Assistant::Ops::Event.where(severity: "info").count }, color: :blue
  17. stat :warn, -> { ::Assistant::Ops::Event.where(severity: "warn").count }, color: :amber
  18. stat :error, -> { ::Assistant::Ops::Event.where(severity: "error").count }, color: :red
  19. stat :last_24h, -> { ::Assistant::Ops::Event.where("created_at >= ?", 24.hours.ago).count }
  20. end
  21. columns do
  22. column :event_type, header: "Event"
  23. column :severity, type: :label, label_color: ->(e) {
  24. case e.severity.to_sym
  25. when :info then :blue
  26. when :warn then :amber
  27. when :error then :red
  28. else :gray
  29. end
  30. }
  31. column :thread, ->(e) { e.thread&.display_title&.truncate(25) }
  32. column :trace_id, ->(e) { e.trace_id&.truncate(12) }
  33. column :created_at, ->(e) { e.created_at.strftime("%b %d, %H:%M:%S") }
  34. end
  35. filters do
  36. filter :event_type, type: :text, placeholder: "Event type..."
  37. filter :severity, type: :select, options: [
  38. [ "All", "" ],
  39. [ "Info", "info" ],
  40. [ "Warning", "warn" ],
  41. [ "Error", "error" ]
  42. ]
  43. filter :trace_id, type: :text, placeholder: "Trace ID..."
  44. filter :thread_id, type: :number, label: "Thread ID"
  45. end
  46. end
  47. show do
  48. sidebar do
  49. panel :event, title: "Event", fields: [ :event_type, :severity ]
  50. panel :ids, title: "Identifiers", fields: [ :trace_id ]
  51. panel :timestamps, title: "Timestamps", fields: [ :created_at ]
  52. end
  53. main do
  54. panel :thread, title: "Thread", fields: [ :thread ]
  55. panel :payload, title: "Payload", fields: [ :payload ]
  56. end
  57. end
  58. exportable :json
  59. end
  60. end
  61. end

app/admin/resources/assistant_memory_proposal_resource.rb

0.0% lines covered

100.0% branches covered

58 relevant lines. 0 lines covered and 58 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Assistant Memory Proposal admin management
  5. #
  6. # Provides read-only access to memory proposals for debugging.
  7. class AssistantMemoryProposalResource < Admin::Base::Resource
  8. model ::Assistant::Memory::MemoryProposal
  9. portal :assistant
  10. section :memory
  11. index do
  12. sortable :created_at, default: :created_at
  13. paginate 30
  14. stats do
  15. stat :total, -> { ::Assistant::Memory::MemoryProposal.count }
  16. stat :pending, -> { ::Assistant::Memory::MemoryProposal.where(status: "pending").count }, color: :amber
  17. stat :accepted, -> { ::Assistant::Memory::MemoryProposal.where(status: "accepted").count }, color: :green
  18. stat :rejected, -> { ::Assistant::Memory::MemoryProposal.where(status: "rejected").count }, color: :red
  19. stat :last_24h, -> { ::Assistant::Memory::MemoryProposal.where("created_at >= ?", 24.hours.ago).count }, color: :blue
  20. end
  21. columns do
  22. column :user, ->(mp) { mp.user&.email_address }
  23. column :status, type: :label, label_color: ->(mp) {
  24. case mp.status.to_sym
  25. when :pending then :amber
  26. when :accepted then :green
  27. when :rejected then :red
  28. else :gray
  29. end
  30. }
  31. column :items_count, ->(mp) { mp.proposed_items&.size || 0 }, header: "Items"
  32. column :thread, ->(mp) { mp.thread&.display_title&.truncate(25) }
  33. column :created_at, ->(mp) { mp.created_at.strftime("%b %d, %H:%M") }
  34. end
  35. filters do
  36. filter :status, type: :select, options: [
  37. [ "All Statuses", "" ],
  38. [ "Pending", "pending" ],
  39. [ "Accepted", "accepted" ],
  40. [ "Rejected", "rejected" ]
  41. ]
  42. filter :user_id, type: :number, label: "User ID"
  43. filter :thread_id, type: :number, label: "Thread ID"
  44. filter :trace_id, type: :text, placeholder: "Trace ID..."
  45. end
  46. end
  47. show do
  48. sidebar do
  49. panel :status, title: "Status", fields: [ :status, :confirmed_by, :confirmed_at ]
  50. panel :ids, title: "Identifiers", fields: [ :trace_id ]
  51. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  52. end
  53. main do
  54. panel :user, title: "User", fields: [ :user ]
  55. panel :thread, title: "Thread", fields: [ :thread ]
  56. panel :proposal, title: "Proposed Items", fields: [ :proposed_items ]
  57. end
  58. end
  59. exportable :json
  60. end
  61. end
  62. end

app/admin/resources/assistant_thread_resource.rb

0.0% lines covered

100.0% branches covered

67 relevant lines. 0 lines covered and 67 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Assistant Thread admin management
  5. #
  6. # Provides read-only operations with search, filtering, and export functionality.
  7. class AssistantThreadResource < Admin::Base::Resource
  8. model ::Assistant::ChatThread
  9. portal :assistant
  10. section :threads
  11. index do
  12. searchable :title
  13. sortable default: :last_activity_at, direction: :desc
  14. paginate 20
  15. stats do
  16. stat :total, -> { ::Assistant::ChatThread.count }
  17. stat :open, -> { ::Assistant::ChatThread.where(status: "open").count }, color: :green
  18. stat :closed, -> { ::Assistant::ChatThread.where(status: "closed").count }, color: :slate
  19. stat :created_last_24h, -> { ::Assistant::ChatThread.where("created_at >= ?", 24.hours.ago).count }, color: :amber
  20. end
  21. columns do
  22. column :id
  23. column :title
  24. column :user, ->(t) { t.user&.email_address }
  25. column :status, sortable: true, type: :label, label_color: ->(t) {
  26. case t.status.to_sym
  27. when :open then :green
  28. when :closed then :slate
  29. else :gray
  30. end
  31. }
  32. column :last_activity_at, ->(t) { t.last_activity_at&.strftime("%b %d, %H:%M") }, sortable: true
  33. column :created_at, ->(t) { t.created_at&.strftime("%b %d, %H:%M") }, sortable: true
  34. end
  35. filters do
  36. filter :status, type: :select, options: [
  37. [ "All Statuses", "" ],
  38. [ "Open", "open" ],
  39. [ "Closed", "closed" ]
  40. ]
  41. filter :user_id, type: :number, label: "User ID"
  42. end
  43. end
  44. show do
  45. sidebar do
  46. panel :details, title: "Details", fields: [ :title, :status ]
  47. panel :user, title: "User", fields: [ :user ]
  48. panel :timestamps, title: "Activity", fields: [ :last_activity_at, :created_at, :updated_at ]
  49. end
  50. main do
  51. panel :messages, title: "Messages", render: :messages_preview
  52. panel :tool_executions, title: "Tool Executions",
  53. association: :tool_executions,
  54. limit: 20,
  55. display: :table,
  56. columns: [ :tool_key, :status, :duration_seconds, :created_at ],
  57. link_to: :internal_developer_assistant_tool_execution_path
  58. panel :turns, title: "Conversation Turns",
  59. association: :turns,
  60. limit: 20,
  61. display: :list,
  62. link_to: :internal_developer_assistant_turn_path
  63. end
  64. end
  65. actions do
  66. action :export, type: :link
  67. end
  68. exportable :json
  69. end
  70. end
  71. end

app/admin/resources/assistant_thread_summary_resource.rb

0.0% lines covered

100.0% branches covered

41 relevant lines. 0 lines covered and 41 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Assistant Thread Summary admin management
  5. #
  6. # Provides read-only access to thread summaries for debugging.
  7. class AssistantThreadSummaryResource < Admin::Base::Resource
  8. model ::Assistant::Memory::ThreadSummary
  9. portal :assistant
  10. section :memory
  11. index do
  12. sortable :created_at, :summary_version, default: :created_at
  13. paginate 30
  14. stats do
  15. stat :total, -> { ::Assistant::Memory::ThreadSummary.count }
  16. stat :with_llm_log, -> { ::Assistant::Memory::ThreadSummary.where.not(llm_api_log_id: nil).count }, color: :blue
  17. stat :recent_24h, -> { ::Assistant::Memory::ThreadSummary.where("created_at >= ?", 24.hours.ago).count }, color: :green
  18. end
  19. columns do
  20. column :thread, ->(ts) { ts.thread&.display_title&.truncate(40) }
  21. column :user, ->(ts) { ts.thread&.user&.email_address }
  22. column :summary_version, header: "Version"
  23. column :created_at, ->(ts) { ts.created_at.strftime("%b %d, %H:%M") }
  24. end
  25. filters do
  26. filter :thread_id, type: :number, label: "Thread ID"
  27. filter :user_id, type: :number, label: "User ID"
  28. filter :version, type: :number, label: "Version"
  29. end
  30. end
  31. show do
  32. sidebar do
  33. panel :meta, title: "Metadata", fields: [ :summary_version, :last_summarized_message ]
  34. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  35. end
  36. main do
  37. panel :thread, title: "Thread", fields: [ :thread ]
  38. panel :content, title: "Summary Content", fields: [ :summary_text ]
  39. panel :llm, title: "LLM API Log", association: :llm_api_log
  40. end
  41. end
  42. exportable :json
  43. end
  44. end
  45. end

app/admin/resources/assistant_tool_execution_resource.rb

0.0% lines covered

100.0% branches covered

83 relevant lines. 0 lines covered and 83 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Assistant Tool Execution admin management
  5. #
  6. # Provides operations to view, approve, enqueue, and replay tool executions.
  7. class AssistantToolExecutionResource < Admin::Base::Resource
  8. model ::Assistant::ToolExecution
  9. portal :assistant
  10. section :tools
  11. index do
  12. sortable :created_at, default: :created_at
  13. paginate 30
  14. stats do
  15. stat :total, -> { ::Assistant::ToolExecution.count }
  16. stat :proposed, -> { ::Assistant::ToolExecution.where(status: "proposed").count }, color: :slate
  17. stat :queued, -> { ::Assistant::ToolExecution.where(status: "queued").count }, color: :blue
  18. stat :running, -> { ::Assistant::ToolExecution.where(status: "running").count }, color: :amber
  19. stat :success, -> { ::Assistant::ToolExecution.where(status: "success").count }, color: :green
  20. stat :error, -> { ::Assistant::ToolExecution.where(status: "error").count }, color: :red
  21. stat :confirmation_required, -> { ::Assistant::ToolExecution.where(requires_confirmation: true).count }, color: :purple
  22. end
  23. columns do
  24. column :created_at, ->(te) { te.created_at.strftime("%b %d, %H:%M") }, header: "Time"
  25. column :tool_key, header: "Tool"
  26. column :status, type: :label, label_color: ->(te) {
  27. case te.status.to_sym
  28. when :proposed then :amber
  29. when :queued then :blue
  30. when :running then :indigo
  31. when :success then :green
  32. when :error then :red
  33. when :cancelled then :slate
  34. when :confirmation_required then :purple
  35. else :gray
  36. end
  37. }
  38. column :requires_confirmation, ->(te) { te.requires_confirmation? ? "Required" : "No" }, header: "Confirmation"
  39. column :trace_id, ->(te) { te.trace_id&.truncate(12) }
  40. end
  41. filters do
  42. filter :status, type: :select, options: [
  43. [ "All Statuses", "" ],
  44. [ "Proposed", "proposed" ],
  45. [ "Queued", "queued" ],
  46. [ "Running", "running" ],
  47. [ "Success", "success" ],
  48. [ "Error", "error" ]
  49. ]
  50. filter :requires_confirmation, type: :select, label: "Confirmation", options: [
  51. [ "All", "" ],
  52. [ "Required", "true" ],
  53. [ "Not Required", "false" ]
  54. ]
  55. filter :tool_key, type: :text, placeholder: "Tool key..."
  56. filter :trace_id, type: :text, placeholder: "Trace ID..."
  57. end
  58. end
  59. show do
  60. sidebar do
  61. panel :status, title: "Status", fields: [ :status, :requires_confirmation ]
  62. panel :approval, title: "Approval", fields: [ :approved_by, :approved_at ]
  63. panel :timing, title: "Timing", fields: [ :started_at, :finished_at, :duration_seconds, :created_at ]
  64. panel :thread, title: "Chat Thread",
  65. association: :chat_thread,
  66. display: :card,
  67. link_to: :internal_developer_assistant_thread_path
  68. end
  69. main do
  70. panel :tool, title: "Tool Information", fields: [ :tool_key, :trace_id, :provider_name, :provider_tool_call_id ]
  71. panel :data, title: "Arguments & Result", render: :tool_args_preview
  72. end
  73. end
  74. actions do
  75. action :approve, method: :post, label: "Approve",
  76. if: ->(te) { te.requires_confirmation && te.approved_by_id.nil? && te.status == "proposed" }
  77. action :enqueue, method: :post, label: "Enqueue",
  78. if: ->(te) { te.status == "proposed" && (!te.requires_confirmation || te.approved_by_id.present?) }
  79. action :replay, method: :post, label: "Replay",
  80. if: ->(te) { %w[success error].include?(te.status) }
  81. bulk_action :bulk_approve, label: "Approve Selected"
  82. bulk_action :bulk_enqueue, label: "Enqueue Selected"
  83. end
  84. exportable :json
  85. end
  86. end
  87. end

app/admin/resources/assistant_tool_resource.rb

0.0% lines covered

100.0% branches covered

90 relevant lines. 0 lines covered and 90 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Assistant Tool admin management
  5. #
  6. # Provides full CRUD for assistant tools with schema editing.
  7. class AssistantToolResource < Admin::Base::Resource
  8. model ::Assistant::Tool
  9. portal :assistant
  10. section :tools
  11. index do
  12. searchable :tool_key, :name, :description
  13. sortable default: :tool_key, direction: :asc
  14. paginate 30
  15. stats do
  16. stat :total, -> { ::Assistant::Tool.count }
  17. stat :enabled, -> { ::Assistant::Tool.where(enabled: true).count }, color: :green
  18. stat :read_only, -> { ::Assistant::Tool.where(risk_level: "read_only").count }, color: :blue
  19. stat :write, -> { ::Assistant::Tool.where("risk_level LIKE 'write%'").count }, color: :amber
  20. end
  21. columns do
  22. column :tool_key, header: "Key", sortable: true
  23. column :name, sortable: true
  24. column :risk_level, header: "Risk", sortable: true
  25. column :enabled, type: :toggle, toggle_field: :enabled
  26. column :requires_confirmation, ->(t) { t.requires_confirmation? ? "Yes" : "No" }, header: "Confirm"
  27. column :timeout_ms, ->(t) { "#{t.timeout_ms}ms" }, header: "Timeout"
  28. end
  29. filters do
  30. filter :enabled, type: :select, options: [
  31. [ "All", "" ],
  32. [ "Enabled", "true" ],
  33. [ "Disabled", "false" ]
  34. ]
  35. filter :risk_level, type: :select, label: "Risk", options: [
  36. [ "All Levels", "" ],
  37. [ "Read Only", "read_only" ],
  38. [ "Write Low", "write_low" ],
  39. [ "Write High", "write_high" ]
  40. ]
  41. filter :requires_confirmation, type: :select, label: "Confirmation", options: [
  42. [ "All", "" ],
  43. [ "Required", "true" ],
  44. [ "Not Required", "false" ]
  45. ]
  46. end
  47. end
  48. form do
  49. section "Tool Definition" do
  50. field :tool_key, required: true, help: "Unique identifier (snake_case)"
  51. field :name, required: true
  52. field :description, type: :textarea, rows: 3, required: true
  53. field :executor_class, required: true, help: "Fully qualified class name"
  54. end
  55. section "Configuration" do
  56. row cols: 2 do
  57. field :risk_level, type: :select, required: true, collection: [
  58. [ "Read Only", "read_only" ],
  59. [ "Write Low", "write_low" ],
  60. [ "Write High", "write_high" ]
  61. ]
  62. field :timeout_ms, type: :number, label: "Timeout (ms)"
  63. end
  64. row cols: 2 do
  65. field :enabled, type: :toggle
  66. field :requires_confirmation, type: :toggle
  67. end
  68. end
  69. section "Schema" do
  70. field :arg_schema, type: :json, label: "Argument Schema",
  71. help: "JSON Schema for tool arguments"
  72. field :rate_limit, type: :json, help: "Rate limiting configuration"
  73. end
  74. end
  75. show do
  76. sidebar do
  77. panel :config, title: "Configuration", fields: [ :risk_level, :enabled, :requires_confirmation ]
  78. panel :execution, title: "Execution", fields: [ :executor_class, :timeout_ms ]
  79. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  80. end
  81. main do
  82. panel :tool, title: "Tool Definition", fields: [ :tool_key, :name, :description ]
  83. panel :schema, title: "Argument Schema", fields: [ :arg_schema ]
  84. panel :limits, title: "Rate Limits", fields: [ :rate_limit ]
  85. end
  86. end
  87. actions do
  88. action :enable, method: :post, unless: ->(t) { t.enabled? }
  89. action :disable, method: :post, if: ->(t) { t.enabled? }
  90. end
  91. exportable :json
  92. end
  93. end
  94. end

app/admin/resources/assistant_turn_resource.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Assistant Turn admin management
  5. #
  6. # Provides read-only access to conversation turns for debugging.
  7. class AssistantTurnResource < Admin::Base::Resource
  8. model ::Assistant::Turn
  9. portal :assistant
  10. section :turns
  11. index do
  12. sortable :created_at, default: :created_at
  13. paginate 30
  14. stats do
  15. stat :total, -> { ::Assistant::Turn.count }
  16. stat :success, -> { ::Assistant::Turn.where(status: "success").count }, color: :green
  17. stat :error, -> { ::Assistant::Turn.where(status: "error").count }, color: :red
  18. stat :last_24h, -> { ::Assistant::Turn.where("created_at >= ?", 24.hours.ago).count }, color: :blue
  19. end
  20. columns do
  21. column :thread, ->(t) { t.thread&.display_title&.truncate(30) }
  22. column :status, type: :label, label_color: ->(t) {
  23. case t.status.to_sym
  24. when :success then :green
  25. when :error then :red
  26. when :pending then :amber
  27. else :gray
  28. end
  29. }
  30. column :trace_id, ->(t) { t.trace_id&.truncate(12) }
  31. column :latency_ms, ->(t) { t.latency_ms ? "#{t.latency_ms}ms" : "—" }, header: "Latency"
  32. column :created_at, ->(t) { t.created_at.strftime("%b %d, %H:%M") }
  33. end
  34. filters do
  35. filter :status, type: :select, options: [
  36. [ "All Statuses", "" ],
  37. [ "Success", "success" ],
  38. [ "Error", "error" ],
  39. [ "Pending", "pending" ]
  40. ]
  41. filter :trace_id, type: :text, placeholder: "Trace ID..."
  42. filter :thread_id, type: :number, label: "Thread ID"
  43. end
  44. end
  45. show do
  46. sidebar do
  47. panel :status, title: "Status", fields: [ :status, :latency_ms ]
  48. panel :ids, title: "Identifiers", fields: [ :trace_id, :uuid ]
  49. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  50. end
  51. main do
  52. panel :thread, title: "Thread", fields: [ :thread ]
  53. panel :messages, title: "Messages", render: :turn_messages_preview
  54. panel :context, title: "Context Snapshot", fields: [ :context_snapshot ]
  55. panel :llm_log, title: "LLM API Log", association: :llm_api_log
  56. end
  57. end
  58. exportable :json
  59. end
  60. end
  61. end

app/admin/resources/assistant_user_memory_resource.rb

0.0% lines covered

100.0% branches covered

58 relevant lines. 0 lines covered and 58 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Assistant User Memory admin management
  5. #
  6. # Provides read-only access to user memories with delete capability.
  7. class AssistantUserMemoryResource < Admin::Base::Resource
  8. model ::Assistant::Memory::UserMemory
  9. portal :assistant
  10. section :memory
  11. index do
  12. searchable :key
  13. sortable :created_at, :expires_at, default: :created_at
  14. paginate 30
  15. stats do
  16. stat :total, -> { ::Assistant::Memory::UserMemory.count }
  17. stat :active, -> { ::Assistant::Memory::UserMemory.active.count }, color: :green
  18. stat :expired, -> { ::Assistant::Memory::UserMemory.where("expires_at IS NOT NULL AND expires_at <= ?", Time.current).count }, color: :red
  19. stat :user_source, -> { ::Assistant::Memory::UserMemory.where(source: "user").count }, color: :blue
  20. stat :assistant_source, -> { ::Assistant::Memory::UserMemory.where(source: "assistant").count }, color: :amber
  21. end
  22. columns do
  23. column :user, ->(um) { um.user&.email_address }
  24. column :key, ->(um) { um.key&.truncate(40) }
  25. column :source
  26. column :active, ->(um) { (um.expires_at.nil? || um.expires_at > Time.current) ? "Yes" : "No" }
  27. column :expires_at, ->(um) { um.expires_at&.strftime("%b %d, %Y") || "Never" }
  28. column :created_at, ->(um) { um.created_at.strftime("%b %d, %H:%M") }
  29. end
  30. filters do
  31. filter :source, type: :select, options: [
  32. [ "All Sources", "" ],
  33. [ "User", "user" ],
  34. [ "Assistant", "assistant" ]
  35. ]
  36. filter :active, type: :select, options: [
  37. [ "All", "" ],
  38. [ "Active", "true" ],
  39. [ "Expired", "false" ]
  40. ]
  41. filter :user_id, type: :number, label: "User ID"
  42. end
  43. end
  44. show do
  45. sidebar do
  46. panel :meta, title: "Metadata", fields: [ :source, :expires_at ]
  47. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  48. end
  49. main do
  50. panel :user, title: "User", fields: [ :user ]
  51. panel :memory, title: "Memory Content", fields: [ :key, :value ]
  52. end
  53. end
  54. actions do
  55. action :delete, method: :delete, label: "Delete",
  56. confirm: "Delete this memory? This cannot be undone.",
  57. color: :danger
  58. end
  59. exportable :json
  60. end
  61. end
  62. end

app/admin/resources/billing_feature_resource.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Billing::Feature management.
  5. class BillingFeatureResource < Admin::Base::Resource
  6. model ::Billing::Feature
  7. portal :payments
  8. section :catalog
  9. index do
  10. searchable :key, :name, :kind, :unit
  11. sortable :key, :updated_at, default: :updated_at
  12. paginate 50
  13. columns do
  14. column :key
  15. column :name
  16. column :kind
  17. column :unit
  18. column :updated_at, ->(f) { f.updated_at&.strftime("%b %d, %H:%M") }
  19. end
  20. end
  21. form do
  22. section "Feature" do
  23. row cols: 2 do
  24. field :key, required: true, help: "Stable identifier used by gating checks."
  25. field :name, required: true
  26. end
  27. row cols: 2 do
  28. field :kind, type: :select, required: true, collection: ::Billing::Feature::KINDS.map { |v| [ v.humanize, v ] }
  29. field :unit, placeholder: "e.g. ai_tokens, interviews"
  30. end
  31. field :description, type: :textarea, rows: 3
  32. field :metadata, type: :json
  33. end
  34. end
  35. end
  36. end
  37. end

app/admin/resources/billing_plan_entitlement_resource.rb

0.0% lines covered

100.0% branches covered

34 relevant lines. 0 lines covered and 34 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Billing::PlanEntitlement management.
  5. class BillingPlanEntitlementResource < Admin::Base::Resource
  6. model ::Billing::PlanEntitlement
  7. portal :payments
  8. section :catalog
  9. index do
  10. searchable :enabled
  11. sortable :updated_at, default: :updated_at
  12. paginate 50
  13. columns do
  14. column :plan, ->(e) { e.plan&.key }, header: "Plan"
  15. column :feature, ->(e) { e.feature&.key }, header: "Feature"
  16. column :enabled, type: :toggle, toggle_field: :enabled
  17. column :limit
  18. column :updated_at, ->(e) { e.updated_at&.strftime("%b %d, %H:%M") }
  19. end
  20. end
  21. form do
  22. section "Entitlement" do
  23. field :plan_id, type: :select, required: true,
  24. collection: -> { ::Billing::Plan.ordered.pluck(:name, :id) }
  25. field :feature_id, type: :select, required: true,
  26. collection: -> { ::Billing::Feature.order(:key).pluck(:key, :id) }
  27. row cols: 2 do
  28. field :enabled, type: :toggle
  29. field :limit, type: :number, help: "Only used for quota features."
  30. end
  31. field :metadata, type: :json
  32. end
  33. end
  34. end
  35. end
  36. end

app/admin/resources/billing_plan_resource.rb

0.0% lines covered

100.0% branches covered

86 relevant lines. 0 lines covered and 86 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Billing::Plan management.
  5. class BillingPlanResource < Admin::Base::Resource
  6. model ::Billing::Plan
  7. portal :payments
  8. section :catalog
  9. index do
  10. searchable :key, :name, :plan_type
  11. sortable :name, :key, :updated_at, default: :updated_at
  12. paginate 50
  13. columns do
  14. column :key
  15. column :name
  16. column :plan_type, header: "Type"
  17. column :interval
  18. column :amount_cents, header: "Amount (cents)"
  19. column :currency
  20. column :published, type: :toggle, toggle_field: :published
  21. column :highlighted, type: :toggle, toggle_field: :highlighted
  22. column :updated_at, ->(p) { p.updated_at&.strftime("%b %d, %H:%M") }
  23. end
  24. filters do
  25. filter :plan_type, type: :select, label: "Type", options: [
  26. [ "All", "" ],
  27. [ "Free", "free" ],
  28. [ "Recurring", "recurring" ],
  29. [ "One-time", "one_time" ]
  30. ]
  31. filter :published, type: :select, label: "Published", options: [
  32. [ "All", "" ],
  33. [ "Published", "true" ],
  34. [ "Unpublished", "false" ]
  35. ]
  36. end
  37. end
  38. form do
  39. section "Plan" do
  40. row cols: 2 do
  41. field :key, required: true, help: "Stable identifier used for gating and pricing surfaces."
  42. field :name, required: true
  43. end
  44. field :description, type: :textarea, rows: 3
  45. row cols: 3 do
  46. field :plan_type, type: :select, required: true, collection: ::Billing::Plan::PLAN_TYPES.map { |v| [ v.humanize, v ] }
  47. field :interval, type: :select, collection: [ [ "", "" ] ] + ::Billing::Plan::INTERVALS.map { |v| [ v.humanize, v ] }
  48. field :currency, required: true, placeholder: "eur"
  49. end
  50. row cols: 3 do
  51. field :amount_cents, type: :number, placeholder: "e.g. 1200"
  52. field :sort_order, type: :number
  53. field :highlighted, type: :toggle
  54. end
  55. field :published, type: :toggle
  56. field :metadata, type: :json, help: "Free-form plan metadata (e.g. marketing copy variants)."
  57. end
  58. end
  59. show do
  60. sidebar do
  61. panel :details, title: "Details", fields: [ :key, :name, :description, :plan_type, :interval, :currency, :amount_cents, :sort_order, :highlighted ]
  62. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  63. end
  64. main do
  65. panel :provider_mappings, title: "Provider Mappings",
  66. association: :provider_mappings,
  67. display: :table,
  68. columns: [ :provider, :product_id, :variant_id ],
  69. link_to: :internal_developer_ops_billing_provider_mapping_path
  70. panel :features, title: "Features",
  71. association: :features, display: :table,
  72. columns: [ :key, :name, :kind, :unit ],
  73. resource: Admin::Resources::BillingFeatureResource,
  74. link_to: :internal_developer_ops_billing_feature_path,
  75. paginate: true, per_page: 10
  76. panel :entitlements, title: "Entitlements",
  77. association: :plan_entitlements,
  78. display: :table,
  79. columns: [ :feature, :enabled, :limit ],
  80. resource: Admin::Resources::BillingPlanEntitlementResource,
  81. link_to: :internal_developer_ops_billing_plan_entitlement_path,
  82. paginate: true, per_page: 5
  83. panel :metadata, title: "Metadata", fields: [ :metadata ]
  84. end
  85. end
  86. end
  87. end
  88. end

app/admin/resources/billing_provider_mapping_resource.rb

0.0% lines covered

100.0% branches covered

36 relevant lines. 0 lines covered and 36 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Billing::ProviderMapping management.
  5. class BillingProviderMappingResource < Admin::Base::Resource
  6. model ::Billing::ProviderMapping
  7. portal :payments
  8. section :providers
  9. index do
  10. searchable :provider, :external_product_id, :external_variant_id
  11. sortable :updated_at, default: :updated_at
  12. paginate 50
  13. columns do
  14. column :provider
  15. column :plan, ->(m) { m.plan&.key }, header: "Plan"
  16. column :external_product_id, header: "Product"
  17. column :external_variant_id, header: "Variant"
  18. column :updated_at, ->(m) { m.updated_at&.strftime("%b %d, %H:%M") }
  19. end
  20. end
  21. form do
  22. section "Provider Mapping" do
  23. field :plan_id, type: :select, required: true,
  24. collection: -> { ::Billing::Plan.ordered.pluck(:name, :id) }
  25. row cols: 2 do
  26. field :provider, type: :select, required: true, collection: ::Billing::ProviderMapping::PROVIDERS.map { |v| [ v.humanize, v ] }
  27. field :external_product_id, placeholder: "LemonSqueezy product id"
  28. end
  29. row cols: 2 do
  30. field :external_variant_id, placeholder: "LemonSqueezy variant id"
  31. field :external_price_id, placeholder: "Optional price id"
  32. end
  33. field :metadata, type: :json, help: "Provider-specific config (e.g., store_id for LemonSqueezy)."
  34. end
  35. end
  36. end
  37. end
  38. end

app/admin/resources/billing_subscription_resource.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Billing::Subscription viewing (read-only via routes).
  5. class BillingSubscriptionResource < Admin::Base::Resource
  6. model ::Billing::Subscription
  7. portal :payments
  8. section :runtime
  9. index do
  10. searchable :provider, :status, :external_subscription_id
  11. sortable :updated_at, :created_at, default: :updated_at
  12. paginate 50
  13. columns do
  14. column :user_id, header: "User ID"
  15. column :provider
  16. column :status, type: :label, label_color: :green
  17. column :plan, ->(s) { s.plan&.key }, header: "Plan"
  18. column :external_subscription_id, header: "External ID"
  19. column :current_period_ends_at, ->(s) { s.current_period_ends_at&.strftime("%b %d, %H:%M") }
  20. column :updated_at, ->(s) { s.updated_at&.strftime("%b %d, %H:%M") }
  21. end
  22. end
  23. show do
  24. sidebar do
  25. panel :status, title: "Status", fields: [ :status, :provider, :external_subscription_id ]
  26. panel :timing, title: "Timing", fields: [ :trial_ends_at, :current_period_starts_at, :current_period_ends_at, :cancel_at_period_end, :cancelled_at ]
  27. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  28. end
  29. main do
  30. panel :metadata, title: "Metadata", fields: [ :metadata ]
  31. end
  32. end
  33. end
  34. end
  35. end

app/admin/resources/billing_webhook_event_resource.rb

0.0% lines covered

100.0% branches covered

51 relevant lines. 0 lines covered and 51 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Billing::WebhookEvent viewing (read-only via routes).
  5. class BillingWebhookEventResource < Admin::Base::Resource
  6. model ::Billing::WebhookEvent
  7. portal :payments
  8. section :runtime
  9. index do
  10. searchable :provider, :event_type, :status, :idempotency_key
  11. sortable :received_at, :updated_at, default: :received_at, direction: :desc
  12. paginate 50
  13. columns do
  14. column :provider
  15. column :event_type, header: "Event"
  16. column :status, type: :label, label_color: ->(we) {
  17. case we.status.to_sym
  18. when :pending then :amber
  19. when :processed then :green
  20. when :failed then :red
  21. when :ignored then :slate
  22. else :gray
  23. end
  24. }
  25. column :received_at
  26. column :processed_at
  27. column :idempotency_key, header: "Key"
  28. end
  29. filters do
  30. filter :status, type: :select, label: "Status", options: [
  31. [ "All", "" ],
  32. [ "Pending", "pending" ],
  33. [ "Processed", "processed" ],
  34. [ "Failed", "failed" ],
  35. [ "Ignored", "ignored" ]
  36. ]
  37. end
  38. end
  39. show do
  40. sidebar do
  41. panel :status, title: "Status", fields: [ :provider, :event_type, :status, :received_at, :processed_at ]
  42. panel :error, title: "Error", fields: [ :error_message ]
  43. end
  44. main do
  45. panel :payload, title: "Payload", fields: [ :payload ]
  46. end
  47. end
  48. actions do
  49. action :replay, method: :post, label: "Replay"
  50. end
  51. end
  52. end
  53. end

app/admin/resources/blog_post_resource.rb

0.0% lines covered

100.0% branches covered

85 relevant lines. 0 lines covered and 85 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Blog Post admin management
  5. #
  6. # Provides full CRUD operations for blog posts with markdown preview.
  7. class BlogPostResource < Admin::Base::Resource
  8. model BlogPost
  9. portal :ops
  10. section :content
  11. index do
  12. searchable :title, :slug
  13. sortable :title, :created_at, :published_at, default: :created_at
  14. paginate 30
  15. stats do
  16. stat :total, -> { BlogPost.count }
  17. stat :published, -> { BlogPost.where(status: "published").count }, color: :green
  18. stat :draft, -> { BlogPost.where(status: "draft").count }, color: :slate
  19. end
  20. columns do
  21. column :title
  22. column :slug
  23. column :status, type: :label, label_color: ->(bp) { bp.status == "published" ? :green : :slate }
  24. column :author_name, header: "Author"
  25. column :published_at, ->(bp) { bp.published_at&.strftime("%b %d, %Y") || "—" }
  26. end
  27. filters do
  28. filter :status, type: :select, options: [
  29. [ "All Statuses", "" ],
  30. [ "Published", "published" ],
  31. [ "Draft", "draft" ]
  32. ]
  33. filter :sort, type: :select, options: [
  34. [ "Recently Added", "recent" ],
  35. [ "Title (A-Z)", "title" ],
  36. [ "Published Date", "published_at" ]
  37. ]
  38. end
  39. end
  40. form do
  41. section "Post Details" do
  42. field :title, required: true
  43. field :slug, help: "URL-friendly identifier (auto-generated if blank)"
  44. field :author_name, label: "Author"
  45. field :excerpt, type: :textarea, rows: 2, help: "Brief summary for listings"
  46. end
  47. section "Cover Image" do
  48. field :cover_image, type: :image,
  49. accept: "image/jpeg,image/png,image/webp",
  50. help: "Recommended size: 1200x630 pixels for best social media preview"
  51. end
  52. section "Content" do
  53. field :body, type: :markdown, rows: 20, help: "Supports Markdown formatting"
  54. end
  55. section "Publishing" do
  56. row cols: 2 do
  57. field :status, type: :select, collection: [
  58. [ "Draft", "draft" ],
  59. [ "Published", "published" ]
  60. ]
  61. field :published_at, type: :datetime
  62. end
  63. field :tag_list, type: :tags, label: "Tags",
  64. collection: -> { ActsAsTaggableOn::Tag.most_used(20).pluck(:name) },
  65. creatable: true,
  66. placeholder: "Add tags...",
  67. help: "Select existing tags or create new ones"
  68. end
  69. end
  70. show do
  71. sidebar do
  72. panel :cover, title: "Cover Image", fields: [ :cover_image ]
  73. panel :meta, title: "Post Info", fields: [ :slug, :status, :author_name ]
  74. panel :dates, title: "Dates", fields: [ :published_at, :created_at, :updated_at ]
  75. end
  76. main do
  77. panel :excerpt, title: "Excerpt", fields: [ :excerpt ]
  78. panel :content, title: "Content", fields: [ :body ]
  79. panel :tags, title: "Tags", fields: [ :tag_list ]
  80. end
  81. end
  82. actions do
  83. action :publish, method: :post, if: ->(bp) { bp.status == "draft" }
  84. action :unpublish, method: :post, if: ->(bp) { bp.status == "published" }
  85. end
  86. exportable :json
  87. end
  88. end
  89. end

app/admin/resources/category_resource.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Category admin management
  5. #
  6. # Provides CRUD operations with search, filtering, and merge functionality.
  7. class CategoryResource < Admin::Base::Resource
  8. model Category
  9. portal :ops
  10. section :content
  11. index do
  12. searchable :name
  13. sortable :name, :created_at, default: :name
  14. paginate 30
  15. stats do
  16. stat :total, -> { Category.count }
  17. stat :with_job_roles, -> { Category.joins(:job_roles).distinct.count }, color: :blue
  18. end
  19. columns do
  20. column :name
  21. column :job_roles_count, ->(c) { c.job_roles.count }, header: "Job Roles"
  22. end
  23. filters do
  24. filter :sort, type: :select, options: [
  25. [ "Name (A-Z)", "name" ],
  26. [ "Recently Added", "recent" ]
  27. ]
  28. end
  29. end
  30. form do
  31. field :name, required: true, placeholder: "Category name"
  32. end
  33. show do
  34. sidebar do
  35. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  36. end
  37. main do
  38. panel :job_roles, title: "Job Roles", association: :job_roles, limit: 20, display: :list
  39. end
  40. end
  41. actions do
  42. action :disable, method: :post, confirm: "Disable this category?"
  43. action :enable, method: :post
  44. action :merge, type: :modal
  45. end
  46. exportable :json, :csv
  47. end
  48. end
  49. end

app/admin/resources/company_feedback_resource.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for CompanyFeedback admin management
  5. #
  6. # Provides read operations for viewing company feedback from users.
  7. class CompanyFeedbackResource < Admin::Base::Resource
  8. model CompanyFeedback
  9. portal :ops
  10. section :support
  11. index do
  12. sortable :created_at, default: :created_at
  13. paginate 30
  14. stats do
  15. stat :total, -> { CompanyFeedback.count }
  16. stat :this_month, -> { CompanyFeedback.where("created_at >= ?", 1.month.ago).count }, color: :blue
  17. end
  18. columns do
  19. column :id
  20. column :company, ->(cf) { cf.interview_application&.company&.name }
  21. column :user, ->(cf) { cf.interview_application&.user&.email_address }
  22. column :rating
  23. column :created_at, ->(cf) { cf.created_at.strftime("%b %d, %Y") }
  24. end
  25. filters do
  26. filter :rating, type: :select, options: (1..5).map { |n| [ "#{n} Stars", n ] }
  27. end
  28. end
  29. show do
  30. section :details, fields: [ :rating, :created_at, :updated_at ]
  31. section :feedback, fields: [ :feedback_text ]
  32. section :application, fields: [ :interview_application ]
  33. end
  34. exportable :json
  35. end
  36. end
  37. end

app/admin/resources/company_resource.rb

0.0% lines covered

100.0% branches covered

67 relevant lines. 0 lines covered and 67 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Company admin management
  5. #
  6. # Provides CRUD operations with search, filtering, and merge functionality.
  7. class CompanyResource < Admin::Base::Resource
  8. model Company
  9. portal :ops
  10. section :content
  11. index do
  12. searchable :name, :website
  13. sortable :name, :created_at, default: :name
  14. paginate 30
  15. stats do
  16. stat :total, -> { Company.count }
  17. stat :with_website, -> { Company.where.not(website: [ nil, "" ]).count }, color: :blue
  18. stat :with_logo, -> { Company.where.not(logo_url: [ nil, "" ]).count }, color: :green
  19. stat :with_job_listings, -> { Company.joins(:job_listings).distinct.count }, color: :amber
  20. end
  21. columns do
  22. column :name, header: "Company"
  23. column :website
  24. column :job_listings_count, ->(c) { c.job_listings.count }, header: "Jobs"
  25. column :applications_count, ->(c) { c.interview_applications.count }, header: "Apps"
  26. end
  27. filters do
  28. filter :sort, type: :select, options: [
  29. [ "Name (A-Z)", "name" ],
  30. [ "Recently Added", "recent" ]
  31. ]
  32. filter :has_website, type: :toggle, label: "Has Website", scope: ->(scope) { scope.where.not(website: [ nil, "" ]) }
  33. end
  34. end
  35. form do
  36. field :name, required: true, placeholder: "Company name"
  37. field :website, type: :url, placeholder: "https://example.com"
  38. field :about, type: :textarea, rows: 4, help: "Brief description of the company"
  39. field :logo_url, type: :url, label: "Logo URL", help: "URL to company logo image"
  40. end
  41. show do
  42. sidebar do
  43. panel :info, title: "Company Info", fields: [ :website, :logo_url ]
  44. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  45. end
  46. main do
  47. panel :about, title: "About", fields: [ :about ]
  48. panel :job_listings, title: "Job Listings",
  49. association: :job_listings,
  50. limit: 10,
  51. display: :table,
  52. columns: [ :title, :status, :remote_type, :created_at ],
  53. link_to: :internal_developer_ops_job_listing_path
  54. panel :applications, title: "Interview Applications",
  55. association: :interview_applications,
  56. limit: 10,
  57. display: :table,
  58. columns: [ :status, :job_listing, :user, :created_at ],
  59. link_to: :internal_developer_ops_interview_application_path
  60. end
  61. end
  62. actions do
  63. action :disable, method: :post, confirm: "Disable this company?", unless: ->(c) { c.disabled? }
  64. action :enable, method: :post, if: ->(c) { c.disabled? }
  65. action :merge, type: :modal
  66. bulk_action :bulk_disable, label: "Disable Selected"
  67. end
  68. exportable :json, :csv
  69. end
  70. end
  71. end

app/admin/resources/connected_account_resource.rb

0.0% lines covered

100.0% branches covered

72 relevant lines. 0 lines covered and 72 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Connected Account admin management
  5. #
  6. # Provides read-only access to OAuth connected accounts for debugging.
  7. class ConnectedAccountResource < Admin::Base::Resource
  8. model ConnectedAccount
  9. portal :ops
  10. section :users
  11. index do
  12. searchable :email
  13. sortable :created_at, :last_synced_at, default: :created_at
  14. paginate 30
  15. stats do
  16. stat :total, -> { ConnectedAccount.count }
  17. stat :google, -> { ConnectedAccount.google.count }, color: :blue
  18. stat :sync_enabled, -> { ConnectedAccount.sync_enabled.count }, color: :green
  19. stat :expired, -> { ConnectedAccount.expired.count }, color: :red
  20. stat :valid, -> { ConnectedAccount.valid_tokens.count }, color: :green
  21. end
  22. columns do
  23. column :user, ->(ca) { ca.user&.email_address }
  24. column :provider
  25. column :email
  26. column :sync_enabled, ->(ca) { ca.sync_enabled? ? "Yes" : "No" }, header: "Sync"
  27. column :token_status, ->(ca) {
  28. if ca.token_expired?
  29. "Expired"
  30. elsif ca.expires_at && ca.expires_at < 5.minutes.from_now
  31. "Expiring"
  32. else
  33. "Valid"
  34. end
  35. }, header: "Token"
  36. column :last_synced_at, ->(ca) { ca.last_synced_at&.strftime("%b %d, %H:%M") || "Never" }
  37. end
  38. filters do
  39. filter :provider, type: :select, options: [
  40. ["All Providers", ""],
  41. ["Google", "google_oauth2"]
  42. ]
  43. filter :sync_enabled, type: :select, label: "Sync", options: [
  44. ["All", ""],
  45. ["Enabled", "true"],
  46. ["Disabled", "false"]
  47. ]
  48. filter :token_status, type: :select, label: "Token", options: [
  49. ["All", ""],
  50. ["Valid", "valid"],
  51. ["Expired", "expired"],
  52. ["Expiring Soon", "expiring_soon"]
  53. ]
  54. filter :sort, type: :select, options: [
  55. ["Recently Added", "recent"],
  56. ["Last Synced", "last_synced"],
  57. ["User Name", "user"]
  58. ]
  59. end
  60. end
  61. show do
  62. sidebar do
  63. panel :account, title: "Account", fields: [:email, :provider, :uid]
  64. panel :sync, title: "Sync Status", fields: [:sync_enabled, :last_synced_at]
  65. panel :token, title: "Token", fields: [:expires_at]
  66. panel :timestamps, title: "Timestamps", fields: [:created_at, :updated_at]
  67. end
  68. main do
  69. panel :user, title: "User", fields: [:user]
  70. panel :emails, title: "Recent Synced Emails", association: :synced_emails, limit: 20, display: :list
  71. end
  72. end
  73. exportable :json
  74. end
  75. end
  76. end

app/admin/resources/email_pipeline_event_resource.rb

0.0% lines covered

100.0% branches covered

61 relevant lines. 0 lines covered and 61 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. class EmailPipelineEventResource < Admin::Base::Resource
  5. model Signals::EmailPipelineEvent
  6. portal :email
  7. section :pipeline
  8. index do
  9. sortable :created_at, default: :created_at, direction: :desc
  10. paginate 50
  11. stats do
  12. stat :total, -> { Signals::EmailPipelineEvent.count }
  13. stat :success, -> { Signals::EmailPipelineEvent.where(status: :success).count }, color: :green
  14. stat :failed, -> { Signals::EmailPipelineEvent.where(status: :failed).count }, color: :red
  15. stat :started, -> { Signals::EmailPipelineEvent.where(status: :started).count }, color: :amber
  16. end
  17. columns do
  18. column :event_type, header: "Event"
  19. column :status, type: :label, label_color: ->(e) {
  20. case e.status.to_sym
  21. when :started then :amber
  22. when :success then :green
  23. when :failed then :red
  24. when :skipped then :purple
  25. else :gray
  26. end
  27. }
  28. column :duration_ms, header: "Duration"
  29. column :step_order, header: "Step"
  30. column :run_id, header: "Run"
  31. column :synced_email_id, header: "Email"
  32. column :created_at, ->(e) { e.created_at.strftime("%b %d, %H:%M:%S") }
  33. end
  34. filters do
  35. filter :status, type: :select, options: [
  36. [ "All", "" ],
  37. [ "Started", "started" ],
  38. [ "Success", "success" ],
  39. [ "Failed", "failed" ],
  40. [ "Skipped", "skipped" ]
  41. ]
  42. filter :event_type, type: :text, placeholder: "event_type..."
  43. end
  44. end
  45. show do
  46. sidebar do
  47. panel :meta, title: "Event", fields: [ :event_type, :status, :duration_ms, :step_order ]
  48. panel :timestamps, title: "Timestamps", fields: [ :started_at, :completed_at, :created_at ]
  49. panel :run, title: "Run", association: :run
  50. panel :email, title: "Email", association: :synced_email
  51. end
  52. main do
  53. panel :error, title: "Error", fields: [ :error_type, :error_message ]
  54. panel :input, title: "Input Payload", fields: [ :input_payload ]
  55. panel :output, title: "Output Payload", fields: [ :output_payload ]
  56. panel :metadata, title: "Metadata", fields: [ :metadata ]
  57. end
  58. end
  59. exportable :json
  60. end
  61. end
  62. end

app/admin/resources/email_pipeline_run_resource.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. class EmailPipelineRunResource < Admin::Base::Resource
  5. model Signals::EmailPipelineRun
  6. portal :email
  7. section :pipeline
  8. index do
  9. sortable :created_at, default: :created_at, direction: :desc
  10. paginate 30
  11. stats do
  12. stat :total, -> { Signals::EmailPipelineRun.count }
  13. stat :started, -> { Signals::EmailPipelineRun.where(status: :started).count }, color: :amber
  14. stat :success, -> { Signals::EmailPipelineRun.where(status: :success).count }, color: :green
  15. stat :failed, -> { Signals::EmailPipelineRun.where(status: :failed).count }, color: :red
  16. end
  17. columns do
  18. column :id
  19. column :status, type: :label, label_color: ->(r) {
  20. case r.status.to_sym
  21. when :started then :amber
  22. when :success then :green
  23. when :failed then :red
  24. else :gray
  25. end
  26. }
  27. column :trigger
  28. column :mode
  29. column :synced_email_id, header: "Email"
  30. column :duration_ms, header: "Duration"
  31. column :created_at, ->(r) { r.created_at.strftime("%b %d, %H:%M:%S") }
  32. end
  33. filters do
  34. filter :status, type: :select, options: [
  35. [ "All", "" ],
  36. [ "Started", "started" ],
  37. [ "Success", "success" ],
  38. [ "Failed", "failed" ]
  39. ]
  40. filter :trigger, type: :text, placeholder: "gmail_sync / manual ..."
  41. end
  42. end
  43. show do
  44. sidebar do
  45. panel :meta, title: "Run", fields: [ :status, :trigger, :mode ]
  46. panel :timestamps, title: "Timestamps", fields: [ :started_at, :completed_at, :created_at ]
  47. panel :email, title: "Synced Email", association: :synced_email
  48. end
  49. main do
  50. panel :events, title: "Events", association: :events, display: :table, columns: [ :step_order, :event_type, :status, :duration_ms, :created_at ]
  51. panel :error, title: "Error", fields: [ :error_type, :error_message ]
  52. panel :metadata, title: "Metadata", fields: [ :metadata ]
  53. end
  54. end
  55. exportable :json
  56. end
  57. end
  58. end

app/admin/resources/email_sender_resource.rb

0.0% lines covered

100.0% branches covered

84 relevant lines. 0 lines covered and 84 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Email Sender admin management
  5. #
  6. # Provides CRUD for email senders discovered during Gmail sync with company assignment.
  7. class EmailSenderResource < Admin::Base::Resource
  8. model EmailSender
  9. portal :ops
  10. section :email
  11. index do
  12. searchable :email, :name, :domain
  13. sortable :email, :created_at, default: :created_at
  14. paginate 30
  15. stats do
  16. stat :total, -> { EmailSender.count }
  17. stat :unassigned, -> { EmailSender.unassigned.count }, color: :amber
  18. stat :assigned, -> { EmailSender.assigned.count }, color: :green
  19. stat :auto_detected, -> { EmailSender.auto_detected.count }, color: :blue
  20. stat :verified, -> { EmailSender.verified.count }, color: :green
  21. end
  22. columns do
  23. column :email
  24. column :name
  25. column :domain
  26. column :company, ->(es) { es.company&.name || "—" }
  27. column :sender_type, type: :label, label_color: ->(es) {
  28. case es.sender_type.to_sym
  29. when :recruiter then :green
  30. when :hiring_manager then :blue
  31. when :hr then :purple
  32. when :ats_system then :indigo
  33. when :company then :green
  34. when :unknown then :slate
  35. else :gray
  36. end
  37. }
  38. column :verified, type: :toggle, toggle_field: :verified
  39. end
  40. filters do
  41. filter :status, type: :select, options: [
  42. [ "All", "" ],
  43. [ "Unassigned", "unassigned" ],
  44. [ "Assigned", "assigned" ],
  45. [ "Auto Detected", "auto_detected" ],
  46. [ "Verified", "verified" ]
  47. ]
  48. filter :sender_type, type: :select, label: "Type", options: EmailSender.sender_types_for_select
  49. filter :sort, type: :select, options: [
  50. [ "Recently Added", "recent" ],
  51. [ "Email Count", "email_count" ],
  52. [ "Last Seen", "last_seen" ],
  53. [ "Alphabetical", "alphabetical" ]
  54. ]
  55. end
  56. end
  57. form do
  58. section "Sender Information" do
  59. field :email, readonly: true
  60. field :name
  61. field :domain, readonly: true
  62. end
  63. section "Assignment" do
  64. field :company_id, type: :searchable_select, label: "Company",
  65. collection: -> { Company.order(:name).pluck(:name, :id) },
  66. placeholder: "Search for a company..."
  67. field :sender_type, type: :select, collection: EmailSender.sender_types_for_select
  68. field :verified, type: :toggle, help: "Verified company assignment"
  69. end
  70. end
  71. show do
  72. sidebar do
  73. panel :info, title: "Sender Info", fields: [ :email, :name, :domain ]
  74. panel :assignment, title: "Assignment", fields: [ :company, :sender_type, :verified ]
  75. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  76. end
  77. main do
  78. panel :emails, title: "Related Emails", association: :synced_emails, limit: 20, display: :list
  79. end
  80. end
  81. actions do
  82. action :verify, method: :post, unless: ->(es) { es.verified? }
  83. bulk_action :bulk_assign, label: "Assign to Company"
  84. end
  85. exportable :json, :csv
  86. end
  87. end
  88. end

app/admin/resources/html_scraping_log_resource.rb

0.0% lines covered

100.0% branches covered

76 relevant lines. 0 lines covered and 76 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for HTML Scraping Log admin management
  5. #
  6. # Provides read-only access to HTML scraping logs for debugging.
  7. class HtmlScrapingLogResource < Admin::Base::Resource
  8. model HtmlScrapingLog
  9. portal :ops
  10. section :scraping
  11. index do
  12. sortable :created_at, default: :created_at
  13. paginate 30
  14. stats do
  15. stat :total_7d, -> { HtmlScrapingLog.recent_period(7).count }
  16. stat :success, -> { HtmlScrapingLog.recent_period(7).where(status: :success).count }, color: :green
  17. stat :partial, -> { HtmlScrapingLog.recent_period(7).where(status: :partial).count }, color: :amber
  18. stat :failed, -> { HtmlScrapingLog.recent_period(7).where(status: :failed).count }, color: :red
  19. end
  20. columns do
  21. column :domain
  22. column :status, type: :label, label_color: ->(log) {
  23. case log.status.to_sym
  24. when :success then :green
  25. when :partial then :amber
  26. when :failed then :red
  27. else :gray
  28. end
  29. }
  30. column :extraction_rate, ->(log) { "#{(log.extraction_rate.to_f * 100).round(1)}%" }, header: "Rate"
  31. column :duration, ->(log) { log.duration_ms ? "#{log.duration_ms}ms" : "—" }
  32. column :fetch_mode, header: "Mode"
  33. column :board_type, header: "Board"
  34. column :created_at, ->(log) { log.created_at.strftime("%b %d, %H:%M") }
  35. end
  36. filters do
  37. filter :status, type: :select, options: [
  38. [ "All Statuses", "" ],
  39. [ "Success", "success" ],
  40. [ "Partial", "partial" ],
  41. [ "Failed", "failed" ]
  42. ]
  43. filter :fetch_mode, type: :select, label: "Mode", options: [
  44. [ "All Modes", "" ],
  45. [ "Direct", "direct" ],
  46. [ "Browser", "browser" ]
  47. ]
  48. filter :board_type, type: :select, label: "Board", options: [
  49. [ "All Boards", "" ],
  50. [ "Greenhouse", "greenhouse" ],
  51. [ "Lever", "lever" ],
  52. [ "Workday", "workday" ],
  53. [ "Custom", "custom" ]
  54. ]
  55. filter :date_from, type: :date, label: "From Date"
  56. filter :date_to, type: :date, label: "To Date"
  57. end
  58. end
  59. show do
  60. sidebar do
  61. panel :meta, title: "Log Info", fields: [ :domain, :status, :fetch_mode, :board_type ]
  62. panel :performance, title: "Performance", fields: [ :duration_ms, :extraction_rate ]
  63. panel :context, title: "Context", fields: [ :extractor_kind, :run_context ]
  64. panel :timestamps, title: "Timestamps", fields: [ :created_at ]
  65. panel :attempt, title: "Scraping Attempt",
  66. association: :scraping_attempt,
  67. link_to: :internal_developer_ops_scraping_attempt_path
  68. panel :job, title: "Job Listing",
  69. association: :job_listing,
  70. link_to: :internal_developer_ops_job_listing_path
  71. end
  72. main do
  73. panel :fields, title: "Field Results", fields: [ :field_results ]
  74. panel :html, title: "HTML Content", fields: [ :raw_html ]
  75. end
  76. end
  77. exportable :json
  78. end
  79. end
  80. end

app/admin/resources/interview_application_resource.rb

0.0% lines covered

100.0% branches covered

83 relevant lines. 0 lines covered and 83 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Interview Application admin management
  5. #
  6. # Provides read-only access to interview applications with filtering and search.
  7. class InterviewApplicationResource < Admin::Base::Resource
  8. model InterviewApplication
  9. portal :ops
  10. section :applications
  11. index do
  12. searchable :user, :company, :job_role
  13. sortable :created_at, :applied_at, default: :created_at
  14. paginate 25
  15. stats do
  16. stat :total, -> { InterviewApplication.count }
  17. stat :active, -> { InterviewApplication.where(status: "active").count }, color: :green
  18. stat :with_rounds, -> { InterviewApplication.joins(:interview_rounds).distinct.count }, color: :blue
  19. stat :with_feedback, -> { InterviewApplication.joins(:company_feedback).distinct.count }, color: :amber
  20. end
  21. columns do
  22. column :user, ->(ia) { ia.user&.email_address }
  23. column :company, ->(ia) { ia.company&.name }
  24. column :job_role, ->(ia) { ia.job_role&.title }
  25. column :status, type: :label, label_color: ->(ia) {
  26. case ia.status.to_sym
  27. when :active then :indigo
  28. when :interviewing then :purple
  29. when :offer then :green
  30. when :rejected then :red
  31. when :archived then :slate
  32. else :slate
  33. end
  34. }
  35. column :pipeline_stage, type: :label, label_color: ->(ia) {
  36. case ia.pipeline_stage.to_sym
  37. when :applied then :indigo
  38. when :screening then :purple
  39. when :interviewing then :blue
  40. when :offer then :green
  41. when :closed then :red
  42. else :gray
  43. end
  44. }
  45. column :applied_at, ->(ia) { ia.applied_at&.strftime("%b %d, %Y") || "—" }
  46. end
  47. filters do
  48. filter :status, type: :select, options: [
  49. [ "All Statuses", "" ],
  50. [ "Active", "active" ],
  51. [ "Interviewing", "interviewing" ],
  52. [ "Offer", "offer" ],
  53. [ "Rejected", "rejected" ],
  54. [ "Withdrawn", "withdrawn" ]
  55. ]
  56. filter :pipeline_stage, type: :select, label: "Stage", options: [
  57. [ "All Stages", "" ],
  58. [ "Applied", "applied" ],
  59. [ "Screening", "screening" ],
  60. [ "Interviewing", "interviewing" ],
  61. [ "Offer", "offer" ],
  62. [ "Closed", "closed" ]
  63. ]
  64. filter :sort, type: :select, options: [
  65. [ "Recently Added", "recent" ],
  66. [ "Applied Date", "applied_at" ],
  67. [ "User Name", "user" ]
  68. ]
  69. end
  70. end
  71. show do
  72. sidebar do
  73. panel :status, title: "Status", fields: [ :status, :pipeline_stage ]
  74. panel :dates, title: "Key Dates", fields: [ :applied_at, :created_at, :updated_at ]
  75. end
  76. main do
  77. panel :user, title: "Applicant", fields: [ :user ]
  78. panel :position, title: "Position", fields: [ :company, :job_role, :job_listing ]
  79. panel :rounds, title: "Interview Rounds", association: :interview_rounds, limit: 20, display: :list
  80. panel :feedback, title: "Company Feedback", association: :company_feedback
  81. panel :emails, title: "Related Emails", association: :synced_emails, limit: 10, display: :list
  82. end
  83. end
  84. exportable :json, :csv
  85. end
  86. end
  87. end

app/admin/resources/interview_round_resource.rb

0.0% lines covered

100.0% branches covered

49 relevant lines. 0 lines covered and 49 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for InterviewRound admin management
  5. #
  6. # Provides read operations for viewing interview rounds.
  7. class InterviewRoundResource < Admin::Base::Resource
  8. model InterviewRound
  9. portal :ops
  10. section :support
  11. index do
  12. sortable :scheduled_at, :created_at, default: :scheduled_at
  13. paginate 30
  14. stats do
  15. stat :total, -> { InterviewRound.count }
  16. stat :scheduled, -> { InterviewRound.where("scheduled_at > ?", Time.current).count }, color: :blue
  17. stat :completed, -> { InterviewRound.where("scheduled_at < ?", Time.current).count }, color: :green
  18. end
  19. columns do
  20. column :id
  21. column :interview_application, ->(ir) { "App ##{ir.interview_application_id}" }
  22. column :round_type
  23. column :scheduled_at, ->(ir) { ir.scheduled_at&.strftime("%b %d, %Y %H:%M") }
  24. column :status
  25. end
  26. filters do
  27. filter :round_type, type: :select, options: [
  28. [ "All Types", "" ],
  29. [ "Phone Screen", "phone_screen" ],
  30. [ "Technical", "technical" ],
  31. [ "Onsite", "onsite" ],
  32. [ "Final", "final" ]
  33. ]
  34. filter :status, type: :select, options: [
  35. [ "All Statuses", "" ],
  36. [ "Scheduled", "scheduled" ],
  37. [ "Completed", "completed" ],
  38. [ "Cancelled", "cancelled" ]
  39. ]
  40. end
  41. end
  42. show do
  43. section :details, fields: [
  44. :round_type, :status, :scheduled_at, :duration_minutes,
  45. :location, :notes, :created_at, :updated_at
  46. ]
  47. section :application, fields: [ :interview_application ]
  48. section :feedback, association: :interview_feedback
  49. end
  50. exportable :json
  51. end
  52. end
  53. end

app/admin/resources/interview_round_type_resource.rb

0.0% lines covered

100.0% branches covered

59 relevant lines. 0 lines covered and 59 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for InterviewRoundType admin management
  5. #
  6. # Provides CRUD operations for managing interview round types per department.
  7. # Round types with no category are universal (available to all departments).
  8. class InterviewRoundTypeResource < Admin::Base::Resource
  9. model InterviewRoundType
  10. portal :ops
  11. section :content
  12. index do
  13. searchable :name, :slug
  14. sortable :name, :position, :created_at, default: :position
  15. paginate 30
  16. stats do
  17. stat :total, -> { InterviewRoundType.count }
  18. stat :universal, -> { InterviewRoundType.universal.count }, color: :blue
  19. stat :enabled, -> { InterviewRoundType.enabled.count }, color: :green
  20. end
  21. columns do
  22. column :name
  23. column :slug
  24. column :department, ->(rt) { rt.category&.name || "Universal" }
  25. column :position
  26. column :rounds_count, ->(rt) { rt.interview_rounds.count }, header: "Rounds"
  27. column :status, ->(rt) { rt.disabled? ? "Disabled" : "Enabled" }
  28. end
  29. filters do
  30. filter :category_id, type: :select, label: "Department",
  31. options: -> { [ [ "Universal", "nil" ] ] + Category.departments.pluck(:name, :id) }
  32. filter :disabled_at, type: :select, label: "Status",
  33. options: [ [ "Enabled", "nil" ], [ "Disabled", "not_nil" ] ]
  34. end
  35. end
  36. form do
  37. field :name, required: true, placeholder: "Round type name (e.g., 'Coding Interview')"
  38. field :slug, required: true, placeholder: "Slug (e.g., 'coding')"
  39. field :category_id, type: :select, label: "Department",
  40. collection: -> { [ [ "Universal (all departments)", nil ] ] + Category.departments.pluck(:name, :id) }
  41. field :description, type: :textarea, rows: 3, placeholder: "Optional description or admin notes"
  42. field :position, type: :number, default: 0, hint: "Lower numbers appear first"
  43. end
  44. show do
  45. sidebar do
  46. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  47. panel :status, title: "Status", fields: [ :disabled_at ]
  48. end
  49. main do
  50. panel :details, title: "Details", fields: [ :name, :slug, :description, :position ]
  51. panel :department, title: "Department", fields: [ :category ]
  52. panel :interview_rounds, title: "Interview Rounds", association: :interview_rounds, limit: 10, display: :list
  53. end
  54. end
  55. actions do
  56. action :disable, method: :post, confirm: "Disable this round type?"
  57. action :enable, method: :post
  58. bulk_action :bulk_disable
  59. bulk_action :bulk_enable
  60. end
  61. exportable :json, :csv
  62. end
  63. end
  64. end

app/admin/resources/job_listing_resource.rb

0.0% lines covered

100.0% branches covered

109 relevant lines. 0 lines covered and 109 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Job Listing admin management
  5. #
  6. # Provides CRUD operations with extraction status visibility and company/role filtering.
  7. class JobListingResource < Admin::Base::Resource
  8. model JobListing
  9. portal :ops
  10. section :jobs
  11. index do
  12. searchable :title
  13. sortable :title, :created_at, :status, default: :created_at
  14. paginate 25
  15. stats do
  16. stat :total, -> { JobListing.count }
  17. stat :active, -> { JobListing.where(status: "active").count }, color: :green
  18. stat :closed, -> { JobListing.where(status: "closed").count }, color: :slate
  19. stat :with_description, -> { JobListing.where.not(description: [ nil, "" ]).count }, color: :blue
  20. end
  21. columns do
  22. column :title
  23. column :company, ->(jl) { jl.company&.name }
  24. column :job_role, ->(jl) { jl.job_role&.title }
  25. column :status, type: :label, label_color: ->(jl) {
  26. case jl.status.to_sym
  27. when :active then :green
  28. when :closed then :slate
  29. when :draft then :slate
  30. else :gray
  31. end
  32. }
  33. column :remote_type
  34. column :created_at, ->(jl) { jl.created_at.strftime("%b %d, %Y") }
  35. end
  36. filters do
  37. filter :status, type: :select, options: [
  38. [ "All Statuses", "" ],
  39. [ "Active", "active" ],
  40. [ "Closed", "closed" ],
  41. [ "Draft", "draft" ]
  42. ]
  43. filter :remote_type, type: :select, label: "Remote", options: [
  44. [ "All Types", "" ],
  45. [ "Remote", "remote" ],
  46. [ "Hybrid", "hybrid" ],
  47. [ "On-site", "onsite" ]
  48. ]
  49. filter :sort, type: :select, options: [
  50. [ "Recently Added", "recent" ],
  51. [ "Title (A-Z)", "title" ],
  52. [ "Status", "status" ]
  53. ]
  54. end
  55. end
  56. form do
  57. section "Basic Information" do
  58. field :title, required: true
  59. field :url, type: :url, label: "Listing URL"
  60. row cols: 2 do
  61. field :status, type: :select, collection: [
  62. [ "Active", "active" ],
  63. [ "Closed", "closed" ],
  64. [ "Draft", "draft" ]
  65. ]
  66. field :remote_type, type: :select, collection: [
  67. [ "Remote", "remote" ],
  68. [ "Hybrid", "hybrid" ],
  69. [ "On-site", "onsite" ]
  70. ]
  71. end
  72. end
  73. section "Content" do
  74. field :description, type: :textarea, rows: 6
  75. field :requirements, type: :textarea, rows: 4
  76. field :responsibilities, type: :textarea, rows: 4
  77. field :benefits, type: :textarea, rows: 3
  78. end
  79. section "Compensation" do
  80. row cols: 3 do
  81. field :salary_min, type: :number, label: "Min Salary"
  82. field :salary_max, type: :number, label: "Max Salary"
  83. field :salary_currency, type: :select, collection: [
  84. [ "USD", "USD" ],
  85. [ "EUR", "EUR" ],
  86. [ "GBP", "GBP" ]
  87. ]
  88. end
  89. field :location
  90. end
  91. end
  92. show do
  93. sidebar do
  94. panel :status, title: "Status", fields: [ :status, :remote_type ]
  95. panel :salary, title: "Compensation", fields: [ :salary_min, :salary_max, :salary_currency, :location ]
  96. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  97. end
  98. main do
  99. panel :info, title: "Listing Info", fields: [ :title, :url ]
  100. panel :content, title: "Description", fields: [ :description ]
  101. panel :requirements, title: "Requirements", fields: [ :requirements, :responsibilities ]
  102. panel :benefits, title: "Benefits", fields: [ :benefits ]
  103. panel :scraping, title: "Scraping Attempts", association: :scraping_attempts, limit: 5, display: :list
  104. end
  105. end
  106. actions do
  107. action :disable, method: :post, confirm: "Disable this job listing?", unless: ->(jl) { jl.status == "closed" }
  108. action :enable, method: :post, if: ->(jl) { jl.status == "closed" }
  109. end
  110. exportable :json, :csv
  111. end
  112. end
  113. end

app/admin/resources/job_role_resource.rb

0.0% lines covered

100.0% branches covered

52 relevant lines. 0 lines covered and 52 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for JobRole admin management
  5. #
  6. # Provides CRUD operations with search, filtering, and merge functionality.
  7. class JobRoleResource < Admin::Base::Resource
  8. model JobRole
  9. portal :ops
  10. section :content
  11. index do
  12. searchable :title, :description
  13. sortable :title, :created_at, default: :title
  14. paginate 30
  15. stats do
  16. stat :total, -> { JobRole.count }
  17. stat :with_listings, -> { JobRole.joins(:job_listings).distinct.count }, color: :blue
  18. stat :user_targets, -> { JobRole.joins(:user_target_job_roles).distinct.count }, color: :amber
  19. end
  20. columns do
  21. column :title
  22. column :category, ->(jr) { jr.category&.name }, type: :label, label_color: :blue
  23. column :job_listings_count, ->(jr) { jr.job_listings.count }, header: "Listings"
  24. column :applications_count, ->(jr) { jr.interview_applications.count }, header: "Apps"
  25. end
  26. filters do
  27. filter :category_id, type: :select, label: "Category",
  28. options: -> { Category.pluck(:name, :id) }
  29. filter :sort, type: :select, options: [
  30. [ "Title (A-Z)", "title" ],
  31. [ "Recently Added", "recent" ]
  32. ]
  33. end
  34. end
  35. form do
  36. field :title, required: true, placeholder: "Job role title"
  37. field :category_id, type: :searchable_select, label: "Category",
  38. collection: "/admin/categories/autocomplete",
  39. create_url: "/admin/categories"
  40. field :description, type: :textarea, rows: 4
  41. end
  42. show do
  43. section :details, fields: [ :title, :description, :created_at, :updated_at ]
  44. section :category, fields: [ :category ]
  45. section :job_listings, association: :job_listings, limit: 10
  46. end
  47. actions do
  48. action :disable, method: :post, confirm: "Disable this job role?"
  49. action :enable, method: :post
  50. action :merge, type: :modal
  51. bulk_action :bulk_disable
  52. end
  53. exportable :json, :csv
  54. end
  55. end
  56. end

app/admin/resources/llm_api_log_resource.rb

0.0% lines covered

100.0% branches covered

75 relevant lines. 0 lines covered and 75 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for LLM API Log admin management
  5. #
  6. # Provides read-only access to LLM API call logs for debugging.
  7. class LlmApiLogResource < Admin::Base::Resource
  8. model ::Ai::LlmApiLog
  9. portal :ai
  10. section :logs
  11. index do
  12. sortable :created_at, default: :created_at, direction: :desc
  13. paginate 30
  14. stats do
  15. stat :total_7d, -> { ::Ai::LlmApiLog.recent_period(7).count }
  16. stat :success, -> { ::Ai::LlmApiLog.recent_period(7).where(status: :success).count }, color: :green
  17. stat :failed, -> { ::Ai::LlmApiLog.recent_period(7).where(status: :failed).count }, color: :red
  18. stat :avg_latency, -> { "#{::Ai::LlmApiLog.recent_period(7).where.not(latency_ms: nil).average(:latency_ms).to_f.round(0)}ms" }, color: :blue
  19. end
  20. columns do
  21. column :operation_type, ->(log) { log.operation_type&.humanize }, header: "Operation"
  22. column :provider
  23. column :model
  24. column :status, type: :label, label_color: ->(log) {
  25. case log.status.to_sym
  26. when :success then :green
  27. when :failed then :red
  28. when :pending then :amber
  29. else :gray
  30. end
  31. }
  32. column :latency, ->(log) { log.latency_ms ? "#{log.latency_ms}ms" : "—" }
  33. column :tokens, ->(log) { "#{log.input_tokens || 0} / #{log.output_tokens || 0}" }, header: "In/Out Tokens"
  34. column :created_at, ->(log) { log.created_at.strftime("%b %d, %H:%M:%S") }, sortable: true
  35. end
  36. filters do
  37. filter :provider, type: :select, options: [
  38. [ "All Providers", "" ],
  39. [ "OpenAI", "openai" ],
  40. [ "Anthropic", "anthropic" ],
  41. [ "Google", "google" ]
  42. ]
  43. filter :status, type: :select, options: [
  44. [ "All Statuses", "" ],
  45. [ "Success", "success" ],
  46. [ "Failed", "failed" ],
  47. [ "Pending", "pending" ]
  48. ]
  49. filter :operation_type, type: :select, label: "Operation", options: [
  50. [ "All Operations", "" ],
  51. [ "Job Extraction", "job_extraction" ],
  52. [ "Chat Completion", "chat_completion" ],
  53. [ "Memory Extraction", "memory_extraction" ]
  54. ]
  55. filter :date_from, type: :date, label: "From Date"
  56. filter :date_to, type: :date, label: "To Date"
  57. end
  58. end
  59. show do
  60. sidebar do
  61. panel :meta, title: "Request Info", fields: [ :operation_type, :provider, :model, :status ]
  62. panel :performance, title: "Performance", fields: [ :latency_ms, :input_tokens, :output_tokens, :estimated_cost_cents ]
  63. panel :timestamps, title: "Timestamps", fields: [ :created_at ]
  64. panel :error, title: "Error Details", fields: [ :error_type, :error_message ]
  65. end
  66. main do
  67. panel :loggable, title: "Source", fields: [ :loggable ]
  68. panel :prompt, title: "Prompt/Input", fields: [ :llm_prompt ]
  69. panel :provider_request_raw, title: "Provider Request (raw)", render: :llm_provider_request_raw
  70. panel :provider_response_raw, title: "Provider Response (raw)", render: :llm_provider_response_raw
  71. panel :provider_error_response_raw, title: "Provider Error Response (raw)", render: :llm_provider_error_response_raw
  72. panel :request, title: "Request Payload", fields: [ :request_payload ]
  73. panel :response, title: "Response Payload", fields: [ :response_payload ]
  74. end
  75. end
  76. exportable :json
  77. end
  78. end
  79. end

app/admin/resources/llm_prompt_resource.rb

0.0% lines covered

100.0% branches covered

86 relevant lines. 0 lines covered and 86 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for LLM Prompt admin management
  5. #
  6. # Provides CRUD operations with activate and duplicate actions.
  7. class LlmPromptResource < Admin::Base::Resource
  8. model ::Ai::LlmPrompt
  9. portal :ai
  10. section :llm
  11. index do
  12. searchable :name, :description
  13. sortable :name, :type, :version, :created_at, default: :type
  14. paginate 20
  15. stats do
  16. stat :total, -> { ::Ai::LlmPrompt.count }
  17. stat :active, -> { ::Ai::LlmPrompt.where(active: true).count }, color: :green
  18. stat :inactive, -> { ::Ai::LlmPrompt.where(active: false).count }, color: :slate
  19. end
  20. columns do
  21. column :name
  22. column :type, ->(p) { p.type.demodulize.titleize }
  23. column :version
  24. column :active, type: :toggle, toggle_field: :active
  25. end
  26. filters do
  27. filter :prompt_type, type: :select, label: "Type", options: [
  28. [ "All Types", "" ],
  29. [ "Job Extraction", "job_extraction" ],
  30. [ "Email Extraction", "email_extraction" ],
  31. [ "Resume Extraction", "resume_extraction" ],
  32. [ "Assistant System", "assistant_system" ],
  33. [ "Thread Summary", "assistant_thread_summary" ],
  34. [ "Memory Proposal", "assistant_memory_proposal" ]
  35. ]
  36. filter :active, type: :select, options: [
  37. [ "All", "" ],
  38. [ "Active Only", "true" ],
  39. [ "Inactive Only", "false" ]
  40. ]
  41. filter :sort, type: :select, options: [
  42. [ "By Type", "type" ],
  43. [ "By Name", "name" ],
  44. [ "By Version", "version" ],
  45. [ "Recently Added", "recent" ]
  46. ]
  47. end
  48. end
  49. form do
  50. section "Basic Information" do
  51. field :name, required: true
  52. field :description, type: :textarea, rows: 3
  53. row cols: 2 do
  54. field :version, type: :number, min: 1
  55. field :active, type: :toggle
  56. end
  57. end
  58. section "Prompt Template" do
  59. field :system_prompt, type: :markdown, rows: 10, help: "System prompt for the LLM. This is used to instruct the LLM on how to behave and what to do."
  60. field :prompt_template, type: :markdown, rows: 20,
  61. help: "Use {{variable_name}} for template variables"
  62. end
  63. end
  64. show do
  65. sidebar do
  66. panel :metadata, title: "Metadata", fields: [ :type, :version, :active ]
  67. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  68. end
  69. main do
  70. panel :info, title: "Information", fields: [ :name, :description ]
  71. panel :system_prompt, title: "System Prompt", fields: [ :system_prompt ]
  72. panel :template, title: "Prompt Template", render: :prompt_template_preview
  73. panel :api_logs, title: "Recent API Logs",
  74. association: :llm_api_logs,
  75. limit: 10,
  76. display: :table,
  77. columns: [ :provider, :model, :status, :latency_ms, :created_at ],
  78. link_to: :internal_developer_ai_llm_api_log_path
  79. end
  80. end
  81. actions do
  82. action :activate, method: :post, label: "Activate",
  83. confirm: "This will deactivate all other prompts of the same type.",
  84. unless: ->(p) { p.active? }
  85. action :duplicate, method: :post, label: "Duplicate"
  86. end
  87. exportable :json
  88. end
  89. end
  90. end

app/admin/resources/llm_provider_config_resource.rb

0.0% lines covered

100.0% branches covered

88 relevant lines. 0 lines covered and 88 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for LLM Provider Config admin management
  5. #
  6. # Provides full CRUD for LLM provider configurations with test action.
  7. class LlmProviderConfigResource < Admin::Base::Resource
  8. model LlmProviderConfig
  9. portal :ai
  10. section :llm
  11. index do
  12. searchable :name, :llm_model
  13. sortable :name, :priority, :provider_type, default: :priority
  14. paginate 20
  15. stats do
  16. stat :total, -> { LlmProviderConfig.count }
  17. stat :enabled, -> { LlmProviderConfig.where(enabled: true).count }, color: :green
  18. stat :disabled, -> { LlmProviderConfig.where(enabled: false).count }, color: :slate
  19. end
  20. columns do
  21. column :name
  22. column :provider_type, header: "Provider"
  23. column :llm_model, header: "Model"
  24. column :priority
  25. column :enabled, type: :toggle, toggle_field: :enabled
  26. column :ready, ->(pc) { pc.ready? ? "Ready" : "Not Ready" }
  27. end
  28. filters do
  29. filter :enabled, type: :select, options: [
  30. [ "All", "" ],
  31. [ "Enabled", "true" ],
  32. [ "Disabled", "false" ]
  33. ]
  34. filter :provider_type, type: :select, label: "Provider", options: [
  35. [ "All Providers", "" ],
  36. [ "OpenAI", "openai" ],
  37. [ "Anthropic", "anthropic" ],
  38. [ "Google", "google" ]
  39. ]
  40. filter :sort, type: :select, options: [
  41. [ "Priority", "priority" ],
  42. [ "Name (A-Z)", "name" ],
  43. [ "Provider", "provider_type" ]
  44. ]
  45. end
  46. end
  47. form do
  48. section "Provider Details" do
  49. field :name, required: true
  50. row cols: 2 do
  51. field :provider_type, type: :select, required: true, collection: [
  52. [ "OpenAI", "openai" ],
  53. [ "Anthropic", "anthropic" ],
  54. [ "Google", "google" ]
  55. ]
  56. field :llm_model, required: true, label: "Model", placeholder: "e.g., gpt-4, claude-3-opus"
  57. end
  58. field :api_endpoint, type: :url, label: "API Endpoint", help: "Optional custom API endpoint"
  59. end
  60. section "Configuration" do
  61. row cols: 3 do
  62. field :priority, type: :number, help: "Lower = higher priority"
  63. field :max_tokens, type: :number
  64. field :temperature, type: :number
  65. end
  66. field :enabled, type: :toggle
  67. end
  68. section "Advanced Settings" do
  69. field :settings, type: :json, help: "Additional provider-specific settings as JSON"
  70. end
  71. end
  72. show do
  73. sidebar do
  74. panel :config, title: "Configuration", fields: [ :provider_type, :llm_model, :priority, :enabled ]
  75. panel :params, title: "Parameters", fields: [ :max_tokens, :temperature ]
  76. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  77. end
  78. main do
  79. panel :details, title: "Provider Details", fields: [ :name, :api_endpoint ]
  80. panel :settings, title: "Advanced Settings", fields: [ :settings ]
  81. end
  82. end
  83. actions do
  84. action :test_provider, method: :post, label: "Test Provider",
  85. if: ->(pc) { pc.enabled? }
  86. action :enable, method: :post, unless: ->(pc) { pc.enabled? }
  87. action :disable, method: :post, if: ->(pc) { pc.enabled? }
  88. end
  89. exportable :json
  90. end
  91. end
  92. end

app/admin/resources/scraping_attempt_resource.rb

0.0% lines covered

100.0% branches covered

96 relevant lines. 0 lines covered and 96 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Scraping Attempt admin management
  5. #
  6. # Provides observability into the scraping pipeline with event timeline.
  7. class ScrapingAttemptResource < Admin::Base::Resource
  8. model ScrapingAttempt
  9. portal :ops
  10. section :scraping
  11. index do
  12. sortable :created_at, :duration_seconds, default: :created_at
  13. paginate 30
  14. stats do
  15. stat :total_7d, -> { ScrapingAttempt.recent_period(7).count }
  16. stat :completed, -> { ScrapingAttempt.recent_period(7).where(status: :completed).count }, color: :green
  17. stat :failed, -> { ScrapingAttempt.recent_period(7).where(status: :failed).count }, color: :red
  18. stat :pending, -> { ScrapingAttempt.where(status: %w[pending fetching extracting]).count }, color: :amber
  19. end
  20. columns do
  21. column :job_listing, ->(sa) { sa.job_listing&.title&.truncate(40) }
  22. column :status, type: :label, label_color: ->(sa) {
  23. case sa.status.to_sym
  24. when :pending then :amber
  25. when :fetching then :yellow
  26. when :extracting then :amber
  27. when :completed then :green
  28. when :failed then :red
  29. when :retrying then :indigo
  30. when :dead_letter then :slate
  31. when :manual then :purple
  32. else :gray
  33. end
  34. }
  35. column :domain
  36. column :extraction_method
  37. column :duration, ->(sa) { sa.duration_seconds ? "#{sa.duration_seconds}s" : "—" }
  38. column :created_at, ->(sa) { sa.created_at.strftime("%b %d, %H:%M") }
  39. end
  40. filters do
  41. filter :status, type: :select, options: [
  42. [ "All Statuses", "" ],
  43. [ "Pending", "pending" ],
  44. [ "Fetching", "fetching" ],
  45. [ "Extracting", "extracting" ],
  46. [ "Completed", "completed" ],
  47. [ "Failed", "failed" ]
  48. ]
  49. filter :extraction_method, type: :select, label: "Method", options: [
  50. [ "All Methods", "" ],
  51. [ "LLM", "llm" ],
  52. [ "Structured", "structured" ],
  53. [ "Fallback", "fallback" ]
  54. ]
  55. filter :date_from, type: :date, label: "From Date"
  56. filter :date_to, type: :date, label: "To Date"
  57. end
  58. end
  59. show do
  60. sidebar do
  61. panel :status, title: "Status", fields: [ :status, :extraction_method, :provider ]
  62. panel :timing, title: "Timing", fields: [ :duration_seconds, :created_at, :updated_at ]
  63. panel :error, title: "Error", fields: [ :failed_step, :error_message ]
  64. panel :job, title: "Job Listing",
  65. association: :job_listing,
  66. link_to: :internal_developer_ops_job_listing_path
  67. end
  68. main do
  69. panel :events, title: "Scraping Events",
  70. association: :scraping_events,
  71. limit: 50,
  72. display: :table,
  73. columns: [ :step_order, :event_type_display, :status, :duration_ms, :created_at ],
  74. link_to: :internal_developer_ops_scraping_event_path
  75. panel :html_logs, title: "HTML Scraping Logs",
  76. association: :html_scraping_logs,
  77. limit: 10,
  78. display: :table,
  79. columns: [ :domain, :status, :extraction_rate, :duration_ms, :created_at ],
  80. link_to: :internal_developer_ops_html_scraping_log_path
  81. panel :logs, title: "LLM API Logs",
  82. association: :llm_api_logs,
  83. limit: 10,
  84. display: :table,
  85. columns: [ :provider, :model, :status, :latency_ms, :created_at ],
  86. link_to: :internal_developer_ai_llm_api_log_path
  87. end
  88. end
  89. actions do
  90. action :mark_failed, method: :post, label: "Mark Failed",
  91. confirm: "Mark this attempt as failed?",
  92. unless: ->(sa) { %w[completed failed].include?(sa.status) }
  93. action :retry_attempt, method: :post, label: "Retry",
  94. if: ->(sa) { sa.status == "failed" }
  95. collection_action :cleanup_stuck, method: :post, label: "Cleanup Stuck Attempts"
  96. end
  97. exportable :json
  98. end
  99. end
  100. end

app/admin/resources/scraping_event_resource.rb

0.0% lines covered

100.0% branches covered

60 relevant lines. 0 lines covered and 60 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Scraping Event admin management
  5. #
  6. # Provides read-only access to scraping events for debugging.
  7. class ScrapingEventResource < Admin::Base::Resource
  8. model ScrapingEvent
  9. portal :ops
  10. section :scraping
  11. index do
  12. sortable :created_at, default: :created_at
  13. paginate 50
  14. stats do
  15. stat :total, -> { ScrapingEvent.count }
  16. stat :success, -> { ScrapingEvent.where(status: :success).count }, color: :green
  17. stat :failed, -> { ScrapingEvent.where(status: :failed).count }, color: :red
  18. stat :skipped, -> { ScrapingEvent.where(status: :skipped).count }, color: :slate
  19. end
  20. columns do
  21. column :step_name, ->(e) { e.event_type_display }, header: "Step"
  22. column :status, type: :label, label_color: ->(e) {
  23. case e.status.to_sym
  24. when :started then :amber
  25. when :success then :green
  26. when :failed then :red
  27. when :skipped then :purple
  28. else :gray
  29. end
  30. }
  31. column :duration, ->(e) { e.duration_ms ? "#{e.duration_ms}ms" : "—" }
  32. column :scraping_attempt, ->(e) { "##{e.scraping_attempt_id}" }, header: "Attempt"
  33. column :created_at, ->(e) { e.created_at.strftime("%b %d, %H:%M:%S") }
  34. end
  35. filters do
  36. filter :status, type: :select, options: [
  37. [ "All Statuses", "" ],
  38. [ "Started", "started" ],
  39. [ "Success", "success" ],
  40. [ "Failed", "failed" ],
  41. [ "Skipped", "skipped" ]
  42. ]
  43. filter :step_name, type: :text, placeholder: "Step name..."
  44. end
  45. end
  46. show do
  47. sidebar do
  48. panel :event, title: "Event Info", fields: [ :step_name, :status, :duration_ms ]
  49. panel :timestamps, title: "Timestamps", fields: [ :started_at, :completed_at, :created_at ]
  50. panel :attempt, title: "Scraping Attempt",
  51. association: :scraping_attempt,
  52. link_to: :internal_developer_ops_scraping_attempt_path
  53. end
  54. main do
  55. panel :error, title: "Error Details", fields: [ :error_type, :error_message ]
  56. panel :input, title: "Input Payload", fields: [ :input_payload ]
  57. panel :output, title: "Output Payload", fields: [ :output_payload ]
  58. panel :metadata, title: "Metadata", fields: [ :metadata ]
  59. end
  60. end
  61. exportable :json
  62. end
  63. end
  64. end

app/admin/resources/setting_resource.rb

0.0% lines covered

100.0% branches covered

53 relevant lines. 0 lines covered and 53 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for application settings admin management
  5. #
  6. # Provides toggle-based management for boolean feature flags.
  7. class SettingResource < Admin::Base::Resource
  8. model ::Setting
  9. portal :ops
  10. section :settings
  11. index do
  12. searchable :name
  13. sortable :name, :updated_at, default: :name
  14. paginate 50
  15. stats do
  16. stat :total, -> { ::Setting.count }
  17. stat :enabled, -> { ::Setting.where(value: true).count }, color: :green
  18. stat :disabled, -> { ::Setting.where(value: false).count }, color: :slate
  19. end
  20. columns do
  21. column :name, ->(s) { s.name.humanize }
  22. column :value, type: :toggle, toggle_field: :value
  23. column :updated_at, ->(s) { s.updated_at&.strftime("%b %d, %H:%M") }
  24. end
  25. filters do
  26. filter :value, type: :select, label: "Status", options: [
  27. [ "All", "" ],
  28. [ "Enabled", "true" ],
  29. [ "Disabled", "false" ]
  30. ]
  31. end
  32. end
  33. form do
  34. section "Setting Configuration" do
  35. field :name, type: :searchable_select, required: true,
  36. collection: ::Setting::AVAILABLE_SETTINGS.map { |s| [ s.humanize, s ] },
  37. placeholder: "Select a setting...",
  38. help: "Choose from available settings"
  39. field :value, type: :toggle, label: "Enabled",
  40. help: "Toggle to enable or disable this feature"
  41. end
  42. end
  43. show do
  44. sidebar do
  45. panel :status, title: "Status", fields: [ :value ]
  46. panel :timestamps, title: "Timestamps", fields: [ :created_at, :updated_at ]
  47. end
  48. main do
  49. panel :setting, title: "Setting", fields: [ :name ]
  50. end
  51. end
  52. actions do
  53. action :toggle, method: :post, label: "Toggle"
  54. end
  55. end
  56. end
  57. end

app/admin/resources/skill_tag_resource.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for SkillTag admin management
  5. #
  6. # Provides CRUD operations with search, filtering, and merge functionality.
  7. class SkillTagResource < Admin::Base::Resource
  8. model SkillTag
  9. portal :ops
  10. section :content
  11. index do
  12. searchable :name
  13. sortable :name, :created_at, default: :name
  14. paginate 50
  15. stats do
  16. stat :total, -> { SkillTag.count }
  17. stat :with_users, -> { SkillTag.joins(:user_skills).distinct.count }, color: :blue
  18. stat :with_resumes, -> { SkillTag.joins(:resume_skills).distinct.count }, color: :green
  19. end
  20. columns do
  21. column :name
  22. column :user_skills_count, ->(st) { st.user_skills.count }, header: "User Skills"
  23. column :resume_skills_count, ->(st) { st.resume_skills.count }, header: "Resume Skills"
  24. end
  25. filters do
  26. filter :sort, type: :select, options: [
  27. [ "Name (A-Z)", "name" ],
  28. [ "Recently Added", "recent" ],
  29. [ "Most Used", "usage" ]
  30. ]
  31. end
  32. end
  33. form do
  34. field :name, required: true, placeholder: "Skill tag name"
  35. end
  36. show do
  37. section :details, fields: [ :name, :created_at, :updated_at ]
  38. section :user_skills, association: :user_skills, limit: 20
  39. section :resume_skills, association: :resume_skills, limit: 20
  40. end
  41. actions do
  42. action :disable, method: :post, confirm: "Disable this skill tag?"
  43. action :enable, method: :post
  44. action :merge, type: :modal
  45. bulk_action :bulk_merge, label: "Merge Selected"
  46. end
  47. exportable :json, :csv
  48. end
  49. end
  50. end

app/admin/resources/support_ticket_resource.rb

0.0% lines covered

100.0% branches covered

75 relevant lines. 0 lines covered and 75 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Support Ticket admin management
  5. #
  6. # Provides listing and status management for support tickets.
  7. class SupportTicketResource < Admin::Base::Resource
  8. model SupportTicket
  9. portal :ops
  10. section :support
  11. index do
  12. searchable :name, :email, :subject, :message
  13. sortable :created_at, :name, :email, default: :created_at
  14. paginate 30
  15. stats do
  16. stat :total, -> { SupportTicket.count }
  17. stat :open, -> { SupportTicket.open.count }, color: :amber
  18. stat :in_progress, -> { SupportTicket.in_progress.count }, color: :blue
  19. stat :resolved, -> { SupportTicket.resolved.count }, color: :green
  20. stat :closed, -> { SupportTicket.closed.count }, color: :slate
  21. end
  22. columns do
  23. column :subject
  24. column :name
  25. column :email
  26. column :status
  27. column :user, ->(st) { st.user ? "Registered" : "Guest" }, header: "Type"
  28. column :created_at, ->(st) { st.created_at.strftime("%b %d, %H:%M") }
  29. end
  30. filters do
  31. filter :status, type: :select, options: [
  32. ["All Statuses", ""],
  33. ["Open", "open"],
  34. ["In Progress", "in_progress"],
  35. ["Resolved", "resolved"],
  36. ["Closed", "closed"]
  37. ]
  38. filter :user_type, type: :select, label: "Sender", options: [
  39. ["All", ""],
  40. ["Registered Users", "registered"],
  41. ["Guests", "guest"]
  42. ]
  43. filter :sort, type: :select, options: [
  44. ["Newest First", "recent"],
  45. ["Oldest First", "oldest"],
  46. ["Name (A-Z)", "name"]
  47. ]
  48. end
  49. end
  50. form do
  51. section "Ticket Status" do
  52. field :status, type: :select, collection: [
  53. ["Open", "open"],
  54. ["In Progress", "in_progress"],
  55. ["Resolved", "resolved"],
  56. ["Closed", "closed"]
  57. ]
  58. end
  59. end
  60. show do
  61. sidebar do
  62. panel :sender, title: "Sender", fields: [:name, :email, :user]
  63. panel :status, title: "Status", fields: [:status]
  64. panel :timestamps, title: "Timestamps", fields: [:created_at, :updated_at]
  65. end
  66. main do
  67. panel :ticket, title: "Ticket Content", fields: [:subject, :message]
  68. end
  69. end
  70. actions do
  71. action :mark_in_progress, method: :post, label: "Start", if: ->(st) { st.status == "open" }
  72. action :resolve, method: :post, if: ->(st) { st.status == "in_progress" }
  73. action :close, method: :post, if: ->(st) { st.status == "resolved" }
  74. action :reopen, method: :post, if: ->(st) { st.status == "closed" }
  75. end
  76. exportable :json, :csv
  77. end
  78. end
  79. end

app/admin/resources/synced_email_resource.rb

0.0% lines covered

100.0% branches covered

128 relevant lines. 0 lines covered and 128 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for Synced Email admin management
  5. #
  6. # Provides viewing and manual matching of synced emails from Gmail.
  7. # Includes signal extraction debugging information.
  8. class SyncedEmailResource < Admin::Base::Resource
  9. model SyncedEmail
  10. portal :email
  11. section :inbox
  12. index do
  13. searchable :subject, :from_email, :from_name
  14. sortable :email_date, :subject, default: :email_date
  15. paginate 30
  16. stats do
  17. stat :total, -> { SyncedEmail.count }
  18. stat :pending, -> { SyncedEmail.where(status: :pending).count }, color: :amber
  19. stat :processed, -> { SyncedEmail.where(status: :processed).count }, color: :green
  20. stat :needs_review, -> { SyncedEmail.needs_review.count }, color: :red
  21. stat :matched, -> { SyncedEmail.matched.count }, color: :blue
  22. stat :pending_extraction, -> { SyncedEmail.where(extraction_status: "pending").count }, color: :amber
  23. stat :extracted, -> { SyncedEmail.where(extraction_status: "completed").count }, color: :green
  24. end
  25. columns do
  26. column :subject, ->(se) { se.subject&.truncate(50) }
  27. column :from_email, header: "From"
  28. column :user, ->(se) { se.user&.email_address }
  29. column :status, type: :label, label_color: ->(se) {
  30. case se.status.to_sym
  31. when :pending then :amber
  32. when :processed then :green
  33. when :ignored then :slate
  34. when :failed then :red
  35. when :auto_ignored then :slate
  36. else :gray
  37. end
  38. }
  39. column :email_type, header: "Type"
  40. column :extraction_status, type: :label, label_color: ->(se) {
  41. case se.extraction_status.to_sym
  42. when :pending then :amber
  43. when :processing then :indigo
  44. when :completed then :green
  45. when :failed then :red
  46. when :skipped then :purple
  47. else :gray
  48. end
  49. }
  50. column :matched, type: :label, label_color: ->(se) { se.interview_application_id? ? :green : :amber }
  51. column :email_date, ->(se) { se.email_date&.strftime("%b %d, %H:%M") }
  52. end
  53. filters do
  54. filter :status, type: :select, options: -> {
  55. [ [ "All Statuses", "" ] ] + SyncedEmail::STATUSES.map { |s| [ s.to_s.humanize, s.to_s ] }
  56. }
  57. filter :email_type, type: :select, label: "Type", options: -> {
  58. [ [ "All Types", "" ] ] + SyncedEmail::EMAIL_TYPES.map { |t| [ t.humanize, t ] }
  59. }
  60. filter :extraction_status, type: :select, label: "Extraction", options: -> {
  61. [ [ "All", "" ] ] + SyncedEmail::EXTRACTION_STATUSES.map { |s| [ s.humanize, s ] }
  62. }
  63. filter :matched, type: :select, options: [
  64. [ "All", "" ],
  65. [ "Matched", "matched" ],
  66. [ "Unmatched", "unmatched" ]
  67. ]
  68. filter :sort, type: :select, options: [
  69. [ "Newest First", "recent" ],
  70. [ "Oldest First", "oldest" ],
  71. [ "Subject", "subject" ]
  72. ]
  73. end
  74. end
  75. form do
  76. section "Email Matching" do
  77. field :interview_application_id, type: :number, label: "Application ID"
  78. field :email_type, type: :select, collection: [ [ "Unknown", "" ] ] + SyncedEmail::EMAIL_TYPES.map { |t| [ t.humanize, t ] }
  79. field :status, type: :select, collection: [
  80. [ "Pending", "pending" ],
  81. [ "Processed", "processed" ],
  82. [ "Ignored", "ignored" ],
  83. [ "Failed", "failed" ],
  84. [ "Auto Ignored", "auto_ignored" ]
  85. ]
  86. end
  87. section "Extraction Status" do
  88. field :extraction_status, type: :select, collection: [
  89. [ "Pending", "pending" ],
  90. [ "Processing", "processing" ],
  91. [ "Completed", "completed" ],
  92. [ "Failed", "failed" ],
  93. [ "Skipped", "skipped" ]
  94. ]
  95. end
  96. end
  97. show do
  98. sidebar do
  99. panel :sender, title: "Sender", fields: [ :from_email, :from_name, :email_sender ]
  100. panel :status, title: "Status", fields: [ :status, :email_type ]
  101. panel :matching, title: "Matching", fields: [ :interview_application ]
  102. panel :timestamps, title: "Dates", fields: [ :email_date, :created_at ]
  103. panel :extraction, title: "Signal Extraction", fields: [
  104. :extraction_status, :extraction_confidence, :extracted_at
  105. ]
  106. end
  107. main do
  108. panel :email, title: "Email Content", fields: [ :subject, :body_snippet ]
  109. panel :extracted_intelligence, title: "Extracted Intelligence", fields: [
  110. :signal_company_name, :signal_company_website, :signal_company_careers_url, :signal_company_domain,
  111. :signal_recruiter_name, :signal_recruiter_email, :signal_recruiter_title, :signal_recruiter_linkedin,
  112. :signal_job_title, :signal_job_department, :signal_job_location, :signal_job_url, :signal_job_salary_hint
  113. ]
  114. panel :actions_and_links, title: "Actions & Links", fields: [
  115. :signal_action_links, :signal_suggested_actions
  116. ]
  117. panel :raw_extraction, title: "Raw Extracted Data (JSON)", fields: [ :extracted_data ]
  118. end
  119. end
  120. actions do
  121. action :mark_processed, method: :post, if: ->(se) { se.status == "pending" }
  122. action :mark_needs_review, method: :post, unless: ->(se) { se.pending? && se.interview_application_id.nil? }
  123. action :ignore, method: :post, unless: ->(se) { se.ignored? }
  124. action :trigger_extraction, method: :post, if: ->(se) { se.extraction_status.in?([ "pending", "failed" ]) }
  125. end
  126. exportable :json
  127. # Custom action to trigger signal extraction
  128. def trigger_extraction
  129. ProcessSignalExtractionJob.perform_later(resource.id)
  130. redirect_to show_path, notice: "Signal extraction queued"
  131. end
  132. end
  133. end
  134. end

app/admin/resources/user_resource.rb

0.0% lines covered

100.0% branches covered

98 relevant lines. 0 lines covered and 98 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Admin
  3. module Resources
  4. # Resource definition for User admin management
  5. #
  6. # Provides read-only access to users with connected accounts and sync visibility.
  7. class UserResource < Admin::Base::Resource
  8. model User
  9. portal :ops
  10. section :users
  11. index do
  12. searchable :email_address, :name
  13. sortable :name, :created_at, default: :created_at
  14. paginate 30
  15. stats do
  16. stat :total, -> { User.count }
  17. stat :with_gmail, -> { User.joins(:connected_accounts).where(connected_accounts: { provider: "google_oauth2" }).distinct.count }, color: :blue
  18. stat :sync_enabled, -> { User.joins(:connected_accounts).where(connected_accounts: { provider: "google_oauth2", sync_enabled: true }).distinct.count }, color: :green
  19. stat :admins, -> { User.where(is_admin: true).count }, color: :amber
  20. end
  21. columns do
  22. column :email_address, header: "Email"
  23. column :name
  24. column :is_admin, ->(u) { u.is_admin? ? "Admin" : "User" }, header: "Role"
  25. column :gmail_status, ->(u) {
  26. account = u.connected_accounts.find_by(provider: "google_oauth2")
  27. account ? (account.sync_enabled? ? "Syncing" : "Connected") : "Not Connected"
  28. }, header: "Gmail"
  29. column :created_at, ->(u) { u.created_at.strftime("%b %d, %Y") }
  30. end
  31. filters do
  32. filter :role, type: :select, options: [
  33. [ "All Users", "" ],
  34. [ "Admins Only", "admin" ],
  35. [ "Regular Users", "user" ]
  36. ]
  37. filter :gmail_status, type: :select, label: "Gmail", options: [
  38. [ "All", "" ],
  39. [ "Connected", "connected" ],
  40. [ "Not Connected", "not_connected" ],
  41. [ "Sync Enabled", "sync_enabled" ]
  42. ]
  43. filter :sort, type: :select, options: [
  44. [ "Recently Joined", "recent" ],
  45. [ "Name (A-Z)", "name" ],
  46. [ "Most Emails", "email_count" ]
  47. ]
  48. end
  49. end
  50. show do
  51. sidebar do
  52. panel :account, title: "Account", fields: [ :email_address, :is_admin, :email_verified_at ]
  53. panel :billing, title: "Billing", fields: [ :billing_admin_access? ]
  54. panel :timestamps, title: "Activity", fields: [ :created_at, :updated_at ]
  55. end
  56. main do
  57. panel :profile, title: "Profile", fields: [ :name ]
  58. panel :billing_debug, title: "Billing Debug", render: :billing_debug_snapshot
  59. panel :connected_accounts, title: "Connected Accounts",
  60. association: :connected_accounts,
  61. display: :table,
  62. columns: [ :provider, :sync_enabled, :created_at ],
  63. link_to: :internal_developer_ops_connected_account_path
  64. panel :threads, title: "Chat Threads",
  65. association: :chat_threads,
  66. limit: 5,
  67. display: :list,
  68. link_to: :internal_developer_assistant_thread_path
  69. panel :applications, title: "Interview Applications",
  70. association: :interview_applications,
  71. limit: 10,
  72. display: :list,
  73. link_to: :internal_developer_ops_interview_application_path
  74. panel :emails, title: "Recent Synced Emails",
  75. association: :synced_emails,
  76. limit: 10,
  77. display: :table,
  78. columns: [ :subject, :from_address, :synced_at ],
  79. link_to: :internal_developer_ops_synced_email_path
  80. end
  81. end
  82. actions do
  83. action :resend_verification_email, method: :post, label: "Resend Verification Email",
  84. confirm: "Send a new verification email to this user?",
  85. unless: ->(u) { u.email_verified? }
  86. action :grant_admin, method: :post, label: "Grant Admin Privileges",
  87. confirm: "Grant admin privileges to this user? They will have full access to the developer portal.",
  88. unless: ->(u) { u.admin? }
  89. action :revoke_admin, method: :post, label: "Revoke Admin Privileges",
  90. confirm: "Revoke admin privileges from this user?",
  91. if: ->(u) { u.admin? }
  92. action :grant_billing_admin_access, method: :post, label: "Grant Billing Admin Access",
  93. confirm: "Grant Admin/Developer billing access (all features) to this user?",
  94. unless: ->(u) { Billing::AdminAccessService.new(user: u).active? }
  95. action :revoke_billing_admin_access, method: :post, label: "Revoke Billing Admin Access",
  96. confirm: "Revoke Admin/Developer billing access from this user?",
  97. if: ->(u) { Billing::AdminAccessService.new(user: u).active? }
  98. end
  99. exportable :json
  100. end
  101. end
  102. end

app/admin_suite/portals/ai.rb

41.07% lines covered

0.0% branches covered

56 relevant lines. 23 lines covered and 33 lines missed.
10 total branches, 0 branches covered and 10 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 AdminSuite.portal :ai do
  3. 1 label "AI Portal"
  4. 1 icon "cpu"
  5. 1 color :cyan
  6. 1 order 30
  7. 1 description "LLM & Machine Learning management"
  8. 1 dashboard do
  9. 1 row do
  10. 1 health_panel "LLM API",
  11. span: 4,
  12. status: lambda {
  13. recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
  14. total = recent_logs.count
  15. successful = recent_logs.where(status: :success).count
  16. then: 0 else: 0 success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
  17. then: 0 if total > 10 && success_rate < 80
  18. else: 0 :critical
  19. then: 0 elsif total > 10 && success_rate < 95
  20. :degraded
  21. else: 0 else
  22. :healthy
  23. end
  24. },
  25. metrics: lambda {
  26. recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
  27. total = recent_logs.count
  28. successful = recent_logs.where(status: :success).count
  29. failed = recent_logs.where(status: :failed).count
  30. then: 0 else: 0 avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
  31. total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
  32. total_cost = (total_cost_cents / 100.0).round(2)
  33. then: 0 else: 0 success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
  34. {
  35. "24h calls" => total,
  36. "success rate" => "#{success_rate}%",
  37. "avg latency" => "#{avg_latency}ms",
  38. "24h cost" => "$#{total_cost}",
  39. "failed" => failed
  40. }
  41. }
  42. 1 chart_panel "API Calls (7 days)",
  43. span: 4,
  44. data: lambda {
  45. (0..6).map do |i|
  46. date = i.days.ago.to_date
  47. count = ::Ai::LlmApiLog.where(created_at: date.beginning_of_day..date.end_of_day).count
  48. { label: date.strftime("%a"), value: count }
  49. end.reverse
  50. }
  51. 1 chart_panel "Cost (7 days, cents)",
  52. span: 4,
  53. data: lambda {
  54. (0..6).map do |i|
  55. date = i.days.ago.to_date
  56. cost_cents = ::Ai::LlmApiLog.where(created_at: date.beginning_of_day..date.end_of_day).sum(:estimated_cost_cents) || 0
  57. { label: date.strftime("%a"), value: cost_cents }
  58. end.reverse
  59. }
  60. end
  61. 1 row do
  62. 1 stat_panel "LLM Prompts", -> { ::Ai::LlmPrompt.count }, span: 2, variant: :mini, color: :slate
  63. 1 stat_panel "Active Prompts", -> { ::Ai::LlmPrompt.where(active: true).count }, span: 2, variant: :mini, color: :green
  64. 1 stat_panel "Provider Configs", -> { ::LlmProviderConfig.count }, span: 2, variant: :mini, color: :slate
  65. 1 stat_panel "Enabled Providers", -> { ::LlmProviderConfig.where(enabled: true).count }, span: 2, variant: :mini, color: :cyan
  66. 1 stat_panel "API Logs", -> { ::Ai::LlmApiLog.count }, span: 2, variant: :mini, color: :slate
  67. 1 stat_panel "Resources", -> { Admin::Base::Resource.resources_for_portal(:ai).count }, span: 2, variant: :mini, color: :cyan
  68. end
  69. 1 row do
  70. 1 cards_panel "LLM Management",
  71. span: 12,
  72. resources: [
  73. { resource_name: "llm_prompts", label: "LLM Prompts", description: "Manage prompt templates for AI models", icon: "scroll-text", count: -> { ::Ai::LlmPrompt.count } },
  74. { resource_name: "llm_provider_configs", label: "Provider Configs", description: "Configure AI providers (OpenAI, Anthropic, etc)", icon: "sliders-horizontal", count: -> { ::LlmProviderConfig.count } },
  75. { resource_name: "llm_api_logs", label: "LLM API Logs", description: "Inspect API calls and errors", icon: "activity", count: -> { ::Ai::LlmApiLog.count } }
  76. ]
  77. end
  78. 1 row do
  79. 1 recent_panel "Recent API Calls",
  80. span: 6,
  81. scope: -> { ::Ai::LlmApiLog.order(created_at: :desc).limit(8) },
  82. view_all_path: ->(view) { view.resources_path(portal: :ai, resource_name: "llm_api_logs") }
  83. 1 recent_panel "Recently Updated Prompts",
  84. span: 6,
  85. scope: -> { ::Ai::LlmPrompt.order(updated_at: :desc).limit(8) },
  86. view_all_path: ->(view) { view.resources_path(portal: :ai, resource_name: "llm_prompts") }
  87. end
  88. end
  89. end

app/admin_suite/portals/assistant.rb

43.55% lines covered

10.0% branches covered

62 relevant lines. 27 lines covered and 35 lines missed.
10 total branches, 1 branches covered and 9 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 AdminSuite.portal :assistant do
  3. 1 label "Assistant Portal"
  4. 1 icon "message-circle"
  5. 1 color :violet
  6. 1 order 40
  7. 1 description "Chat, Tools & Memory management"
  8. 1 dashboard do
  9. 1 row do
  10. 1 health_panel "Assistant System",
  11. span: 4,
  12. status: lambda {
  13. recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
  14. total = recent_executions.count
  15. successful = recent_executions.where(status: :completed).count
  16. pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
  17. failed = recent_executions.where(status: :failed).count
  18. then: 0 else: 0 success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
  19. then: 0 if failed > 10
  20. else: 0 :critical
  21. then: 0 elsif pending > 20 || (total > 10 && success_rate < 70)
  22. :degraded
  23. else: 0 else
  24. :healthy
  25. end
  26. },
  27. metrics: lambda {
  28. recent_threads = ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago)
  29. recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
  30. total = recent_executions.count
  31. successful = recent_executions.where(status: :completed).count
  32. pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
  33. failed = recent_executions.where(status: :failed).count
  34. then: 0 else: 0 success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
  35. {
  36. "24h threads" => recent_threads.count,
  37. "24h tool runs" => total,
  38. "success rate" => "#{success_rate}%",
  39. "pending approval" => pending,
  40. "failed" => failed
  41. }
  42. }
  43. 1 chart_panel "Threads (7 days)",
  44. span: 4,
  45. data: lambda {
  46. (0..6).map do |i|
  47. date = i.days.ago.to_date
  48. count = ::Assistant::ChatThread.where(created_at: date.beginning_of_day..date.end_of_day).count
  49. { label: date.strftime("%a"), value: count }
  50. end.reverse
  51. }
  52. 1 chart_panel "Tool Runs (7 days)",
  53. span: 4,
  54. data: lambda {
  55. (0..6).map do |i|
  56. date = i.days.ago.to_date
  57. count = ::Assistant::ToolExecution.where(created_at: date.beginning_of_day..date.end_of_day).count
  58. { label: date.strftime("%a"), value: count }
  59. end.reverse
  60. }
  61. end
  62. 1 row do
  63. 1 stat_panel "Threads", -> { ::Assistant::ChatThread.count }, span: 2, variant: :mini, color: :slate
  64. 1 stat_panel "Open", -> { ::Assistant::ChatThread.where(status: "open").count }, span: 2, variant: :mini, color: :green
  65. 1 stat_panel "Tool Runs", -> { ::Assistant::ToolExecution.count }, span: 2, variant: :mini, color: :slate
  66. 1 stat_panel "Active Tools", -> { ::Assistant::Tool.where(enabled: true).count }, span: 2, variant: :mini, color: :green
  67. 1 stat_panel "Memories", -> { ::Assistant::Memory::UserMemory.count }, span: 2, variant: :mini, color: :cyan
  68. 1 stat_panel "Resources", -> { Admin::Base::Resource.resources_for_portal(:assistant).count }, span: 2, variant: :mini, color: :violet
  69. end
  70. 1 row do
  71. 1 recent_panel "Recent Threads",
  72. span: 6,
  73. scope: -> { ::Assistant::ChatThread.includes(:user).order(created_at: :desc).limit(8) },
  74. view_all_path: ->(view) { view.resources_path(portal: :assistant, resource_name: "assistant_threads") }
  75. 1 recent_panel "Recent Tool Runs",
  76. span: 4,
  77. scope: -> { ::Assistant::ToolExecution.order(created_at: :desc).limit(8) },
  78. view_all_path: ->(view) { view.resources_path(portal: :assistant, resource_name: "assistant_tool_executions") }
  79. 1 stat_panel "Pending Approvals",
  80. -> { ::Assistant::ToolExecution.where(status: :pending_approval).count },
  81. span: 2,
  82. variant: :mini,
  83. color: :amber
  84. end
  85. 1 row do
  86. 1 cards_panel "Assistant Management",
  87. span: 12,
  88. resources: begin
  89. items = [
  90. 1 { resource_name: "assistant_tools", label: "Tools", description: "Manage tool definitions", icon: "wrench", count: -> { ::Assistant::Tool.count } },
  91. { resource_name: "assistant_threads", label: "Threads", description: "Monitor ongoing conversations", icon: "message-square", count: -> { ::Assistant::ChatThread.count } },
  92. { resource_name: "assistant_turns", label: "Turns", description: "Conversation turns", icon: "repeat", count: -> { ::Assistant::Turn.count } }
  93. ]
  94. # `Assistant::Event` is optional; some deployments don't ship it.
  95. 1 then: 0 else: 1 if defined?(::Assistant::Event)
  96. items << { resource_name: "assistant_events", label: "Events", description: "System events", icon: "clock", count: -> { ::Assistant::Event.count } }
  97. end
  98. 1 items
  99. end
  100. end
  101. end
  102. end

app/admin_suite/portals/email.rb

45.65% lines covered

0.0% branches covered

46 relevant lines. 21 lines covered and 25 lines missed.
10 total branches, 0 branches covered and 10 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 AdminSuite.portal :email do
  3. 1 label "Email Portal"
  4. 1 icon "inbox"
  5. 1 color :emerald
  6. 1 order 20
  7. 1 description "Synced emails + Signals pipeline timeline"
  8. 1 dashboard do
  9. 1 row do
  10. 1 health_panel "Signals Pipeline (24h)",
  11. span: 4,
  12. status: lambda {
  13. recent_runs = Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago)
  14. total = recent_runs.count
  15. successful = recent_runs.where(status: :success).count
  16. failed = recent_runs.where(status: :failed).count
  17. running = recent_runs.where(status: :started).count
  18. then: 0 else: 0 success_rate = total.positive? ? (successful.to_f / total * 100).round : 0
  19. then: 0 if failed > 10 || (total > 20 && success_rate < 80)
  20. else: 0 :critical
  21. then: 0 elsif failed.positive? || (total > 20 && success_rate < 95) || running > 20
  22. :degraded
  23. else: 0 else
  24. :healthy
  25. end
  26. },
  27. metrics: lambda {
  28. recent_runs = Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago)
  29. total = recent_runs.count
  30. successful = recent_runs.where(status: :success).count
  31. failed = recent_runs.where(status: :failed).count
  32. running = recent_runs.where(status: :started).count
  33. then: 0 else: 0 success_rate = total.positive? ? (successful.to_f / total * 100).round : 0
  34. then: 0 else: 0 avg_duration = recent_runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
  35. {
  36. "24h runs" => total,
  37. "success rate" => "#{success_rate}%",
  38. "failed" => failed,
  39. "running" => running,
  40. "avg duration" => "#{avg_duration}ms"
  41. }
  42. }
  43. 1 stat_panel "Synced Emails",
  44. -> { SyncedEmail.count },
  45. span: 4,
  46. color: :green
  47. 1 stat_panel "Pipeline Runs (24h)",
  48. -> { Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago).count },
  49. span: 4,
  50. color: :cyan
  51. end
  52. 1 row do
  53. 1 stat_panel "Matched", -> { SyncedEmail.matched.count }, span: 2, variant: :mini, color: :green
  54. 1 stat_panel "Unmatched", -> { SyncedEmail.unmatched.count }, span: 2, variant: :mini, color: :amber
  55. 1 stat_panel "Needs Review", -> { SyncedEmail.needs_review.count }, span: 2, variant: :mini, color: :red
  56. 1 stat_panel "Runs (24h)", -> { Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago).count }, span: 2, variant: :mini, color: :slate
  57. 1 stat_panel "Events (24h)", -> { Signals::EmailPipelineEvent.where("created_at > ?", 24.hours.ago).count }, span: 2, variant: :mini, color: :slate
  58. 1 stat_panel "Resources", -> { Admin::Base::Resource.resources_for_portal(:email).count }, span: 2, variant: :mini, color: :emerald
  59. end
  60. 1 row do
  61. 1 recent_panel "Recent Emails",
  62. span: 7,
  63. scope: -> { SyncedEmail.order(email_date: :desc).limit(8) },
  64. view_all_path: ->(view) { view.resources_path(portal: :email, resource_name: "synced_emails") }
  65. 1 recent_panel "Recent Pipeline Runs",
  66. span: 5,
  67. scope: -> { Signals::EmailPipelineRun.order(created_at: :desc).limit(8) },
  68. view_all_path: ->(view) { view.resources_path(portal: :email, resource_name: "email_pipeline_runs") }
  69. end
  70. end
  71. end

app/admin_suite/portals/ops.rb

42.62% lines covered

0.0% branches covered

61 relevant lines. 26 lines covered and 35 lines missed.
8 total branches, 0 branches covered and 8 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 AdminSuite.portal :ops do
  3. 1 label "Ops Portal"
  4. 1 icon "settings"
  5. 1 color :amber
  6. 1 order 10
  7. 1 description "Content, Users, Email & Scraping Management"
  8. 1 dashboard do
  9. 1 row do
  10. 1 health_panel "Scraping Pipeline",
  11. span: 4,
  12. status: lambda {
  13. recent = ScrapingAttempt.where("created_at > ?", 24.hours.ago)
  14. total = recent.count
  15. completed = recent.where(status: :completed).count
  16. stuck = recent.where(status: :processing).where("updated_at < ?", 30.minutes.ago).count
  17. then: 0 else: 0 rate = total > 0 ? (completed.to_f / total * 100).round : 0
  18. then: 0 if stuck > 5 || (total > 10 && rate < 50)
  19. else: 0 :critical
  20. then: 0 elsif stuck > 0 || (total > 10 && rate < 80)
  21. :degraded
  22. else: 0 else
  23. :healthy
  24. end
  25. },
  26. metrics: lambda {
  27. recent = ScrapingAttempt.where("created_at > ?", 24.hours.ago)
  28. total = recent.count
  29. completed = recent.where(status: :completed).count
  30. failed = recent.where(status: :failed).count
  31. stuck = recent.where(status: :processing).where("updated_at < ?", 30.minutes.ago).count
  32. then: 0 else: 0 rate = total > 0 ? (completed.to_f / total * 100).round : 0
  33. {
  34. "24h attempts" => total,
  35. "success rate" => "#{rate}%",
  36. "failed" => failed,
  37. "stuck" => stuck
  38. }
  39. }
  40. 1 chart_panel "Scraping (7 days)",
  41. span: 4,
  42. data: lambda {
  43. (0..6).map do |i|
  44. date = i.days.ago.to_date
  45. count = ScrapingAttempt.where(created_at: date.beginning_of_day..date.end_of_day).count
  46. { label: date.strftime("%a"), value: count }
  47. end.reverse
  48. }
  49. 1 chart_panel "User Signups (7 days)",
  50. span: 4,
  51. data: lambda {
  52. (0..6).map do |i|
  53. date = i.days.ago.to_date
  54. count = User.where(created_at: date.beginning_of_day..date.end_of_day).count
  55. { label: date.strftime("%a"), value: count }
  56. end.reverse
  57. }
  58. end
  59. 1 row do
  60. 1 stat_panel "Companies", -> { Company.count }, span: 2, variant: :mini, color: :slate
  61. 1 stat_panel "Job Roles", -> { JobRole.count }, span: 2, variant: :mini, color: :slate
  62. 1 stat_panel "Categories", -> { Category.count }, span: 2, variant: :mini, color: :slate
  63. 1 stat_panel "Skill Tags", -> { SkillTag.count }, span: 2, variant: :mini, color: :slate
  64. 1 stat_panel "Users", -> { User.count }, span: 2, variant: :mini, color: :green
  65. 1 stat_panel "Applications", -> { InterviewApplication.count }, span: 2, variant: :mini, color: :cyan
  66. end
  67. 1 row do
  68. 1 stat_panel "Job Listings", -> { JobListing.count }, span: 2, variant: :mini, color: :slate
  69. 1 stat_panel "Resources", -> { Admin::Base::Resource.resources_for_portal(:ops).count }, span: 2, variant: :mini, color: :amber
  70. end
  71. 1 row do
  72. 1 cards_panel "Content Management",
  73. span: 12,
  74. resources: [
  75. { resource_name: "companies", label: "Companies", description: "Company profiles and associations", icon: "building-2", count: -> { Company.count } },
  76. { resource_name: "job_roles", label: "Job Roles", description: "Job titles and definitions", icon: "briefcase", count: -> { JobRole.count } },
  77. { resource_name: "categories", label: "Categories", description: "Job role categories", icon: "layers", count: -> { Category.count } },
  78. { resource_name: "skill_tags", label: "Skill Tags", description: "Skills and competencies", icon: "tag", count: -> { SkillTag.count } },
  79. { resource_name: "job_listings", label: "Job Listings", description: "Jobs content management", icon: "file-text", count: -> { JobListing.count } },
  80. { resource_name: "blog_posts", label: "Blog Posts", description: "Blog content management", icon: "pencil-line", count: -> { BlogPost.count } }
  81. ]
  82. end
  83. 1 row do
  84. 1 recent_panel "Recent Users",
  85. span: 6,
  86. scope: -> { User.order(created_at: :desc).limit(5) },
  87. view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "users") }
  88. 1 recent_panel "Recent Applications",
  89. span: 6,
  90. scope: -> { InterviewApplication.includes(:user, :company).order(created_at: :desc).limit(5) },
  91. view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "interview_applications") }
  92. end
  93. end
  94. end

app/admin_suite/portals/payments.rb

80.0% lines covered

100.0% branches covered

20 relevant lines. 16 lines covered and 4 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 AdminSuite.portal :payments do
  3. 1 label "Payments Portal"
  4. 1 icon "credit-card"
  5. 1 color :emerald
  6. 1 order 50
  7. 1 description "Billing plans, subscriptions, and webhooks"
  8. 1 dashboard do
  9. 1 row do
  10. 1 stat_panel "Plans", -> { Billing::Plan.count }, span: 2, variant: :mini, color: :slate
  11. 1 stat_panel "Features", -> { Billing::Feature.count }, span: 2, variant: :mini, color: :slate
  12. 1 stat_panel "Entitlements", -> { Billing::PlanEntitlement.count }, span: 2, variant: :mini, color: :slate
  13. 1 stat_panel "Mappings", -> { Billing::ProviderMapping.count }, span: 2, variant: :mini, color: :slate
  14. 1 stat_panel "Subscriptions", -> { Billing::Subscription.count }, span: 2, variant: :mini, color: :cyan
  15. 1 stat_panel "Webhook Pending", -> { Billing::WebhookEvent.where(status: "pending").count }, span: 2, variant: :mini, color: :amber
  16. end
  17. 1 row do
  18. 1 cards_panel "Billing Management",
  19. span: 12,
  20. resources: [
  21. { resource_name: "billing_plans", label: "Plans", description: "Subscription plans", icon: "package", count: -> { Billing::Plan.count } },
  22. { resource_name: "billing_features", label: "Features", description: "Entitlement features", icon: "badge-check", count: -> { Billing::Feature.count } },
  23. { resource_name: "billing_subscriptions", label: "Subscriptions", description: "Customer subscriptions", icon: "receipt", count: -> { Billing::Subscription.count } },
  24. { resource_name: "billing_webhook_events", label: "Webhook Events", description: "Incoming provider webhooks", icon: "webhook", count: -> { Billing::WebhookEvent.count } }
  25. ]
  26. end
  27. end
  28. end

app/channels/application_cable/connection.rb

0.0% lines covered

100.0% branches covered

64 relevant lines. 0 lines covered and 64 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. require "base64"
  2. require "json"
  3. module ApplicationCable
  4. class Connection < ActionCable::Connection::Base
  5. identified_by :current_user
  6. def connect
  7. set_current_user || reject_unauthorized_connection
  8. end
  9. private
  10. def set_current_user
  11. # Prefer the explicit signed session cookie (if present).
  12. session_id = normalize_session_id(cookies.signed[:session_id])
  13. # Fall back to Rails cookie_store session (contains session[:auth_session_id]).
  14. if session_id.blank?
  15. session_id = extract_session_id_from_rails_session_cookie
  16. end
  17. return nil if session_id.blank?
  18. if (session = Session.find_by(id: session_id))
  19. self.current_user = session.user
  20. end
  21. end
  22. # When using :cookie_store, authenticated session id is stored inside the Rails session cookie
  23. # under :auth_session_id. ActionCable connections don't have access to controller `session`,
  24. # so we read/decrypt the session cookie directly.
  25. #
  26. # @return [String, nil]
  27. def extract_session_id_from_rails_session_cookie
  28. key = Rails.application.config.session_options[:key].to_s
  29. return nil if key.blank?
  30. # cookie_store uses encrypted cookies in modern Rails.
  31. raw_session = cookies.encrypted[key]
  32. raw_session = cookies.signed[key] if raw_session.nil?
  33. return nil if raw_session.blank?
  34. # Prefer Hash-shaped session payloads.
  35. if raw_session.is_a?(Hash)
  36. id = raw_session["auth_session_id"] || raw_session[:auth_session_id]
  37. return id.to_s if id.present?
  38. end
  39. nil
  40. rescue StandardError
  41. nil
  42. end
  43. # Normalizes the signed cookie payload to a usable session id.
  44. #
  45. # Rails cookie jars may return:
  46. # - a String/Integer session id
  47. # - a metadata Hash (depending on cookie serializer/version)
  48. #
  49. # @param raw [Object]
  50. # @return [String, nil]
  51. def normalize_session_id(raw)
  52. return nil if raw.blank?
  53. if raw.is_a?(Hash)
  54. payload = raw["_rails"] || raw[:_rails]
  55. message = payload.is_a?(Hash) ? (payload["message"] || payload[:message]) : nil
  56. raw = message if message.present?
  57. end
  58. value = raw.to_s
  59. return value if value.match?(/\A\d+\z/)
  60. # Some Rails cookie formats base64-encode the message.
  61. if value.match?(/\A[A-Za-z0-9+\/]+=*\z/)
  62. decoded = Base64.decode64(value).to_s
  63. return decoded if decoded.match?(/\A\d+\z/)
  64. # Some formats wrap the signed value as JSON metadata:
  65. # {"_rails":{"message":"<base64>","exp":...,"pur":...}}
  66. if decoded.strip.start_with?("{")
  67. begin
  68. data = JSON.parse(decoded)
  69. payload = data["_rails"] || data.dig("_rails")
  70. message = payload.is_a?(Hash) ? payload["message"] : nil
  71. if message.is_a?(String)
  72. inner = Base64.decode64(message).to_s
  73. return inner if inner.match?(/\A\d+\z/)
  74. end
  75. rescue JSON::ParserError
  76. # ignore
  77. end
  78. end
  79. end
  80. nil
  81. rescue ArgumentError
  82. nil
  83. end
  84. end
  85. end

app/constraints/developer_authenticated_constraint.rb

40.0% lines covered

0.0% branches covered

5 relevant lines. 2 lines covered and 3 lines missed.
2 total branches, 0 branches covered and 2 branches missed.
    
  1. # frozen_string_literal: true
  2. # Routing constraint that requires developer authentication via TechWright SSO
  3. #
  4. # Used to protect routes like Mission Control Jobs that need admin access
  5. # but don't go through the normal controller authentication flow.
  6. #
  7. # @example Usage in routes
  8. # constraints DeveloperAuthenticatedConstraint.new do
  9. # mount MissionControl::Jobs::Engine, at: "/jobs"
  10. # end
  11. #
  12. 1 class DeveloperAuthenticatedConstraint
  13. # Checks if the request has a valid developer session
  14. #
  15. # @param request [ActionDispatch::Request] The incoming request
  16. # @return [Boolean] True if developer is authenticated
  17. 1 def matches?(request)
  18. developer_id = request.session[:developer_id]
  19. then: 0 else: 0 return false if developer_id.blank?
  20. Developer.enabled.exists?(id: developer_id)
  21. end
  22. end

app/controllers/ai_assistant/queries_controller.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module AiAssistant
  3. # Controller for handling AI assistant queries
  4. class QueriesController < ApplicationController
  5. # POST /ai_assistant/ask
  6. def ask
  7. question = params[:question]
  8. thread_uuid = params[:thread_uuid]
  9. thread_id = params[:thread_id]
  10. client_request_uuid = params[:client_request_uuid].presence
  11. page_context = build_page_context_from_params
  12. if question.blank?
  13. render json: { error: "Question cannot be blank" }, status: :unprocessable_entity
  14. return
  15. end
  16. trial_result = Billing::TrialUnlockService.new(user: Current.user, trigger: :first_ai_request).run
  17. thread =
  18. if thread_uuid.present?
  19. Assistant::ChatThread.where(user: Current.user).find_by(uuid: thread_uuid)
  20. elsif thread_id.present?
  21. Assistant::ChatThread.where(user: Current.user).find_by(id: thread_id)
  22. end
  23. result = Assistant::Chat::Orchestrator.new(
  24. user: Current.user,
  25. thread: thread,
  26. message: question,
  27. page_context: page_context,
  28. client_request_uuid: client_request_uuid
  29. ).call
  30. render json: {
  31. answer: result[:assistant_message].content,
  32. thread_id: result[:thread].id,
  33. thread_uuid: result[:thread].uuid,
  34. trace_id: result[:trace_id],
  35. tool_calls: result[:tool_calls],
  36. trial_unlocked: trial_result[:unlocked] == true,
  37. trial_expires_at: trial_result[:expires_at]
  38. }
  39. end
  40. private
  41. # Builds page context from request parameters
  42. #
  43. # @return [Hash] Page context for the assistant
  44. def build_page_context_from_params
  45. context = {}
  46. context[:resume_id] = params[:resume_id].to_i if params[:resume_id].present?
  47. context[:job_listing_id] = params[:job_listing_id].to_i if params[:job_listing_id].present?
  48. if params[:interview_application_id].present?
  49. raw = params[:interview_application_id].to_s.strip
  50. if raw.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
  51. context[:interview_application_uuid] = raw
  52. elsif raw.match?(/\A\d+\z/)
  53. context[:interview_application_id] = raw.to_i
  54. else
  55. context[:interview_application_uuid] = raw
  56. end
  57. end
  58. context[:opportunity_id] = params[:opportunity_id].to_i if params[:opportunity_id].present?
  59. context[:include_full_resume] = true if params[:include_full_resume] == "true" || params[:include_full_resume] == true
  60. context.compact
  61. end
  62. end
  63. end

app/controllers/ai_assistant/tool_executions_controller.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module AiAssistant
  3. class ToolExecutionsController < ApplicationController
  4. # POST /ai_assistant/tool_executions/:id/enqueue
  5. def enqueue
  6. tool_execution = scoped_tool_executions.find(params[:id])
  7. if tool_execution.requires_confirmation && tool_execution.approved_by_id.nil?
  8. render json: { error: "This tool requires approval before it can be executed." }, status: :unprocessable_entity
  9. return
  10. end
  11. enqueued = false
  12. tool_execution.with_lock do
  13. if tool_execution.status == "proposed"
  14. tool_execution.update!(status: "queued")
  15. enqueued = true
  16. end
  17. end
  18. AssistantToolExecutionJob.perform_later(tool_execution.id) if enqueued
  19. render json: { status: tool_execution.status, tool_execution_id: tool_execution.id }
  20. end
  21. # POST /ai_assistant/tool_executions/:id/approve
  22. def approve
  23. tool_execution = scoped_tool_executions.find(params[:id])
  24. unless tool_execution.requires_confirmation
  25. render json: { error: "This tool does not require approval." }, status: :unprocessable_entity
  26. return
  27. end
  28. enqueued = false
  29. tool_execution.with_lock do
  30. if %w[success running].include?(tool_execution.status)
  31. # no-op
  32. else
  33. tool_execution.update!(
  34. approved_by: (tool_execution.approved_by || Current.user),
  35. approved_at: (tool_execution.approved_at || Time.current),
  36. status: (tool_execution.status == "proposed" ? "queued" : tool_execution.status)
  37. )
  38. enqueued = (tool_execution.status == "queued")
  39. end
  40. end
  41. AssistantToolExecutionJob.perform_later(tool_execution.id, approved_by_id: Current.user.id) if enqueued
  42. render json: { status: tool_execution.status, tool_execution_id: tool_execution.id }
  43. end
  44. private
  45. def scoped_tool_executions
  46. Assistant::ToolExecution.joins(:thread).where(assistant_threads: { user_id: Current.user.id })
  47. end
  48. end
  49. end

app/controllers/api/v1/base_controller.rb

0.0% lines covered

100.0% branches covered

9 relevant lines. 0 lines covered and 9 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Base controller for API v1 endpoints
  3. # Provides JSON-only responses and authentication
  4. #
  5. # Note: CSRF protection is enabled (default Rails behavior) since these APIs
  6. # are consumed by same-origin JavaScript using session-based auth.
  7. # The frontend includes the X-CSRF-Token header in all mutating requests.
  8. class Api::V1::BaseController < ApplicationController
  9. before_action :authenticate_api_user!
  10. private
  11. # Authenticates user for API requests
  12. # Uses session-based auth (same as web app)
  13. # @return [void]
  14. def authenticate_api_user!
  15. unless Current.user
  16. render json: { error: "Unauthorized" }, status: :unauthorized
  17. end
  18. end
  19. end

app/controllers/api/v1/companies_controller.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # API controller for companies search and creation
  3. class Api::V1::CompaniesController < Api::V1::BaseController
  4. # GET /api/v1/companies
  5. # Search companies
  6. def index
  7. @companies = Company.enabled.alphabetical
  8. if params[:q].present?
  9. @companies = @companies.where("name ILIKE ?", "%#{params[:q]}%")
  10. end
  11. @companies = @companies.limit(params[:limit] || 50)
  12. render json: {
  13. companies: @companies.map { |company| company_json(company) },
  14. total: @companies.size
  15. }
  16. end
  17. # POST /api/v1/companies
  18. # Creates a new company (user-created)
  19. def create
  20. @company = Company.new(company_params)
  21. if @company.save
  22. render json: { success: true, company: company_json(@company) }, status: :created
  23. else
  24. render json: { success: false, errors: @company.errors.full_messages }, status: :unprocessable_entity
  25. end
  26. end
  27. private
  28. # Strong parameters for company creation
  29. # @return [ActionController::Parameters]
  30. def company_params
  31. params.require(:company).permit(:name, :website, :about)
  32. end
  33. # Serializes company for JSON response
  34. # @param company [Company]
  35. # @return [Hash]
  36. def company_json(company)
  37. {
  38. id: company.id,
  39. name: company.name,
  40. website: company.website,
  41. logo_url: company.logo_url
  42. }
  43. end
  44. end

app/controllers/api/v1/departments_controller.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # API controller for departments (job role categories)
  3. class Api::V1::DepartmentsController < Api::V1::BaseController
  4. # GET /api/v1/departments
  5. # List all departments with their job role counts
  6. def index
  7. @departments = Category.departments
  8. render json: {
  9. departments: @departments.map { |dept| department_json(dept) }
  10. }
  11. end
  12. private
  13. # Serializes department for JSON response
  14. # @param dept [Category]
  15. # @return [Hash]
  16. def department_json(dept)
  17. {
  18. id: dept.id,
  19. name: dept.name,
  20. job_role_count: dept.job_roles.enabled.count
  21. }
  22. end
  23. end

app/controllers/api/v1/domains_controller.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # API controller for domains search and creation
  3. class Api::V1::DomainsController < Api::V1::BaseController
  4. # GET /api/v1/domains
  5. # Search domains
  6. def index
  7. @domains = Domain.enabled.alphabetical
  8. if params[:q].present?
  9. @domains = @domains.search(params[:q])
  10. end
  11. @domains = @domains.limit(params[:limit] || 50)
  12. render json: {
  13. domains: @domains.map { |domain| domain_json(domain) },
  14. total: @domains.size
  15. }
  16. end
  17. # POST /api/v1/domains
  18. # Creates a new domain (user-created)
  19. def create
  20. @domain = Domain.new(domain_params)
  21. if @domain.save
  22. render json: { success: true, domain: domain_json(@domain) }, status: :created
  23. else
  24. render json: { success: false, errors: @domain.errors.full_messages }, status: :unprocessable_entity
  25. end
  26. end
  27. private
  28. # Strong parameters for domain creation
  29. # @return [ActionController::Parameters]
  30. def domain_params
  31. params.require(:domain).permit(:name, :description)
  32. end
  33. # Serializes domain for JSON response
  34. # @param domain [Domain]
  35. # @return [Hash]
  36. def domain_json(domain)
  37. {
  38. id: domain.id,
  39. name: domain.name,
  40. slug: domain.slug,
  41. description: domain.description
  42. }
  43. end
  44. end

app/controllers/api/v1/job_roles_controller.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # API controller for job roles search and creation
  3. class Api::V1::JobRolesController < Api::V1::BaseController
  4. # GET /api/v1/job_roles
  5. # Search job roles with optional department filter
  6. def index
  7. @job_roles = JobRole.enabled.alphabetical
  8. if params[:q].present?
  9. @job_roles = @job_roles.search(params[:q])
  10. end
  11. if params[:department_id].present?
  12. @job_roles = @job_roles.by_department(params[:department_id])
  13. end
  14. @job_roles = @job_roles.includes(:category).limit(params[:limit] || 50)
  15. render json: {
  16. job_roles: @job_roles.map { |role| job_role_json(role) },
  17. total: @job_roles.size
  18. }
  19. end
  20. # POST /api/v1/job_roles
  21. # Creates a new job role (user-created)
  22. def create
  23. @job_role = JobRole.new(job_role_params)
  24. # Assign to department if provided
  25. if params[:department_id].present?
  26. @job_role.category = Category.find_by(id: params[:department_id], kind: :job_role)
  27. end
  28. if @job_role.save
  29. render json: { success: true, job_role: job_role_json(@job_role) }, status: :created
  30. else
  31. render json: { success: false, errors: @job_role.errors.full_messages }, status: :unprocessable_entity
  32. end
  33. end
  34. private
  35. # Strong parameters for job role creation
  36. # @return [ActionController::Parameters]
  37. def job_role_params
  38. params.require(:job_role).permit(:title, :description)
  39. end
  40. # Serializes job role for JSON response
  41. # @param role [JobRole]
  42. # @return [Hash]
  43. def job_role_json(role)
  44. {
  45. id: role.id,
  46. title: role.title,
  47. description: role.description,
  48. department_id: role.category_id,
  49. department_name: role.department_name
  50. }
  51. end
  52. end

app/controllers/application_controller.rb

71.43% lines covered

0.0% branches covered

14 relevant lines. 10 lines covered and 4 lines missed.
2 total branches, 0 branches covered and 2 branches missed.
    
  1. 1 class ApplicationController < ActionController::Base
  2. 1 include Authentication
  3. 1 include TurnstileHelper
  4. 1 include Paginatable
  5. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  6. 1 allow_browser versions: :modern
  7. # Changes to the importmap will invalidate the etag for HTML responses
  8. 1 stale_when_importmap_changes
  9. # Set layout based on authentication status
  10. 1 layout :determine_layout
  11. 1 private
  12. 1 def determine_layout
  13. then: 0 if authenticated?
  14. "authenticated"
  15. else: 0 else
  16. "application"
  17. end
  18. end
  19. 1 def authenticated?
  20. Current.user.present?
  21. end
  22. end

app/controllers/archived_jobs_controller.rb

0.0% lines covered

100.0% branches covered

9 relevant lines. 0 lines covered and 9 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class ArchivedJobsController < ApplicationController
  3. # GET /archived_jobs
  4. def index
  5. @archived_opportunities = Current.user.opportunities.archived
  6. .order(archived_at: :desc)
  7. @archived_saved_jobs = Current.user.saved_jobs.archived
  8. .includes(:opportunity)
  9. .order(archived_at: :desc)
  10. end
  11. end

app/controllers/assistant/messages_controller.rb

0.0% lines covered

100.0% branches covered

75 relevant lines. 0 lines covered and 75 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. # POST /assistant/threads/:thread_uuid/messages
  4. #
  5. # Creates a user message immediately and enqueues async LLM processing.
  6. # Returns turbo_stream with user message + thinking indicator.
  7. class MessagesController < ApplicationController
  8. def create
  9. @thread = ChatThread.where(user: Current.user).find_by!(uuid: params[:thread_uuid])
  10. question = params[:content].to_s.strip
  11. client_request_uuid = params[:client_request_uuid].presence
  12. if question.blank?
  13. head :unprocessable_entity
  14. return
  15. end
  16. # Check for duplicate request (idempotency)
  17. if client_request_uuid.present?
  18. existing_turn = Assistant::Turn.where(thread: @thread, client_request_uuid: client_request_uuid).first
  19. if existing_turn
  20. @user_message = existing_turn.user_message
  21. @assistant_message = existing_turn.assistant_message
  22. @show_thinking = false
  23. respond_to do |format|
  24. format.turbo_stream
  25. format.html { redirect_to assistant_thread_path(@thread) }
  26. end
  27. return
  28. end
  29. end
  30. # Build page context from params
  31. # This allows the frontend to indicate which page the user is on,
  32. # enabling context-aware features like including full resume text
  33. page_context = build_page_context_from_params
  34. # Create user message immediately
  35. trace_id = SecureRandom.uuid
  36. @user_message = @thread.messages.create!(
  37. role: "user",
  38. content: question,
  39. metadata: { trace_id: trace_id, page_context: page_context }
  40. )
  41. # Update thread activity
  42. @thread.update!(last_activity_at: Time.current)
  43. # Auto-generate title from first message if thread has no title
  44. if @thread.title.blank? && @thread.messages.where(role: "user").count == 1
  45. @thread.update!(title: question.truncate(50))
  46. end
  47. # Enqueue async LLM processing
  48. @trace_id = trace_id
  49. AssistantChatJob.perform_later(
  50. thread_id: @thread.id,
  51. user_id: Current.user.id,
  52. user_message_id: @user_message.id,
  53. trace_id: trace_id,
  54. client_request_uuid: client_request_uuid
  55. )
  56. @show_thinking = true
  57. maybe_unlock_insight_trial_after_first_ai_request
  58. respond_to do |format|
  59. format.turbo_stream
  60. format.html { redirect_to assistant_thread_path(@thread) }
  61. end
  62. end
  63. private
  64. # Builds page context from request parameters
  65. #
  66. # Supported context fields:
  67. # - resume_id: User is viewing a resume (triggers full resume text inclusion)
  68. # - job_listing_id: User is viewing a job listing
  69. # - interview_application_id: User is viewing an application
  70. # - opportunity_id: User is viewing an opportunity
  71. # - include_full_resume: Explicit flag to include full resume text
  72. #
  73. # @return [Hash] Page context for the assistant
  74. def build_page_context_from_params
  75. context = {}
  76. # Extract context IDs from params
  77. context[:resume_id] = params[:resume_id].to_i if params[:resume_id].present?
  78. context[:job_listing_id] = params[:job_listing_id].to_i if params[:job_listing_id].present?
  79. if params[:interview_application_id].present?
  80. raw = params[:interview_application_id].to_s.strip
  81. if raw.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
  82. context[:interview_application_uuid] = raw
  83. elsif raw.match?(/\A\d+\z/)
  84. context[:interview_application_id] = raw.to_i
  85. else
  86. # FriendlyId slug is likely present; treat as UUID-ish identifier
  87. context[:interview_application_uuid] = raw
  88. end
  89. end
  90. context[:opportunity_id] = params[:opportunity_id].to_i if params[:opportunity_id].present?
  91. # Explicit flags
  92. context[:include_full_resume] = true if params[:include_full_resume] == "true" || params[:include_full_resume] == true
  93. context.compact
  94. end
  95. # Unlocks the insight-triggered trial on the user's first AI assistant request.
  96. #
  97. # @return [void]
  98. def maybe_unlock_insight_trial_after_first_ai_request
  99. result = Billing::TrialUnlockService.new(user: Current.user, trigger: :first_ai_request).run
  100. return unless result[:unlocked]
  101. flash.now[:notice] = "You've unlocked Pro insights for 72 hours."
  102. end
  103. end
  104. end

app/controllers/assistant/threads_controller.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. class ThreadsController < ApplicationController
  4. def index
  5. @threads = ChatThread.where(user: Current.user).order(last_activity_at: :desc, created_at: :desc)
  6. end
  7. def show
  8. @thread = ChatThread.where(user: Current.user).find_by!(uuid: params[:uuid])
  9. # Tool messages are persisted for provider replay, but should not render as chat bubbles.
  10. @messages = @thread.messages.where(role: %w[user assistant]).order(:created_at)
  11. @tool_executions = @thread.tool_executions.order(created_at: :desc)
  12. @tool_action_items = @tool_executions.select { |te| te.status.in?(%w[proposed queued running]) }
  13. end
  14. def create
  15. thread = ChatThread.create!(user: Current.user, title: nil, status: "open", last_activity_at: Time.current)
  16. redirect_to assistant_thread_path(thread)
  17. end
  18. end
  19. end

app/controllers/assistant/tool_executions_controller.rb

0.0% lines covered

100.0% branches covered

232 relevant lines. 0 lines covered and 232 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. class ToolExecutionsController < ApplicationController
  4. def approve
  5. tool_execution = scoped.find_by!(uuid: params[:uuid])
  6. if tool_execution.requires_confirmation? == false
  7. redirect_back fallback_location: assistant_thread_path(tool_execution.thread), alert: "This tool does not require approval."
  8. return
  9. end
  10. tool_execution = consolidate_batch_tool_executions!(tool_execution)
  11. invalid_errors = validate_tool_execution_args(tool_execution)
  12. if invalid_errors.any?
  13. mark_tool_execution_invalid!(tool_execution, invalid_errors)
  14. switch_originating_message_to_placeholder!(tool_execution)
  15. broadcast_tool_proposals(tool_execution.thread)
  16. enqueue_followup_if_ready(tool_execution)
  17. return redirect_back fallback_location: assistant_thread_path(tool_execution.thread), alert: "Couldn't run this action: #{invalid_errors.join(', ')}"
  18. end
  19. enqueued = false
  20. tool_execution.with_lock do
  21. if %w[success running].include?(tool_execution.status)
  22. # no-op
  23. else
  24. tool_execution.update!(
  25. approved_by: (tool_execution.approved_by || Current.user),
  26. approved_at: (tool_execution.approved_at || Time.current),
  27. status: (tool_execution.status == "proposed" ? "queued" : tool_execution.status)
  28. )
  29. enqueued = (tool_execution.status == "queued")
  30. end
  31. end
  32. AssistantToolExecutionJob.perform_later(tool_execution.id, approved_by_id: Current.user.id) if enqueued
  33. if enqueued
  34. switch_originating_message_to_placeholder!(tool_execution)
  35. broadcast_tool_proposals(tool_execution.thread)
  36. end
  37. respond_to do |format|
  38. format.turbo_stream do
  39. streams = []
  40. streams << turbo_stream.replace(ActionView::RecordIdentifier.dom_id(tool_execution.thread, :tool_executions), partial: "assistant/threads/tool_proposals", locals: {
  41. thread: tool_execution.thread,
  42. tool_executions: tool_execution.thread.tool_executions.where(status: %w[proposed queued running]).order(created_at: :asc)
  43. })
  44. streams << turbo_stream.replace(ActionView::RecordIdentifier.dom_id(tool_execution.assistant_message), partial: "assistant/threads/message", locals: { message: tool_execution.assistant_message }) if enqueued
  45. render turbo_stream: streams
  46. end
  47. format.html { redirect_back fallback_location: assistant_thread_path(tool_execution.thread), notice: (enqueued ? "Approved and enqueued." : "Already running or finished.") }
  48. end
  49. end
  50. def enqueue
  51. tool_execution = scoped.find_by!(uuid: params[:uuid])
  52. if tool_execution.requires_confirmation? && tool_execution.approved_by_id.nil?
  53. redirect_back fallback_location: assistant_thread_path(tool_execution.thread), alert: "This tool requires approval before it can be executed."
  54. return
  55. end
  56. tool_execution = consolidate_batch_tool_executions!(tool_execution)
  57. invalid_errors = validate_tool_execution_args(tool_execution)
  58. if invalid_errors.any?
  59. mark_tool_execution_invalid!(tool_execution, invalid_errors)
  60. switch_originating_message_to_placeholder!(tool_execution)
  61. broadcast_tool_proposals(tool_execution.thread)
  62. enqueue_followup_if_ready(tool_execution)
  63. return redirect_back fallback_location: assistant_thread_path(tool_execution.thread), alert: "Couldn't run this action: #{invalid_errors.join(', ')}"
  64. end
  65. enqueued = false
  66. tool_execution.with_lock do
  67. if tool_execution.status == "proposed"
  68. tool_execution.update!(status: "queued")
  69. enqueued = true
  70. end
  71. end
  72. AssistantToolExecutionJob.perform_later(tool_execution.id) if enqueued
  73. if enqueued
  74. switch_originating_message_to_placeholder!(tool_execution)
  75. broadcast_tool_proposals(tool_execution.thread)
  76. end
  77. respond_to do |format|
  78. format.turbo_stream do
  79. streams = []
  80. streams << turbo_stream.replace(ActionView::RecordIdentifier.dom_id(tool_execution.thread, :tool_executions), partial: "assistant/threads/tool_proposals", locals: {
  81. thread: tool_execution.thread,
  82. tool_executions: tool_execution.thread.tool_executions.where(status: %w[proposed queued running]).order(created_at: :asc)
  83. })
  84. streams << turbo_stream.replace(ActionView::RecordIdentifier.dom_id(tool_execution.assistant_message), partial: "assistant/threads/message", locals: { message: tool_execution.assistant_message }) if enqueued
  85. render turbo_stream: streams
  86. end
  87. format.html { redirect_back fallback_location: assistant_thread_path(tool_execution.thread), notice: (enqueued ? "Enqueued." : "Already queued or processed.") }
  88. end
  89. end
  90. private
  91. def scoped
  92. ::Assistant::ToolExecution.joins(:thread).where(assistant_threads: { user_id: Current.user.id })
  93. end
  94. def batchable_tool_key?(tool_key)
  95. tool_key.to_s.in?(%w[add_target_company add_target_job_role remove_target_company remove_target_job_role])
  96. end
  97. def consolidate_batch_tool_executions!(tool_execution)
  98. return tool_execution unless batchable_tool_key?(tool_execution.tool_key)
  99. siblings = scoped.where(
  100. thread_id: tool_execution.thread_id,
  101. assistant_message_id: tool_execution.assistant_message_id,
  102. tool_key: tool_execution.tool_key,
  103. status: "proposed",
  104. requires_confirmation: true,
  105. approved_by_id: nil
  106. ).order(created_at: :asc).to_a
  107. return tool_execution if siblings.size <= 1
  108. primary = siblings.first
  109. merged_args =
  110. case tool_execution.tool_key.to_s
  111. when "add_target_company"
  112. merge_company_args(siblings.map(&:args))
  113. when "add_target_job_role"
  114. merge_job_role_args(siblings.map(&:args))
  115. when "remove_target_company"
  116. merge_company_args(siblings.map(&:args))
  117. when "remove_target_job_role"
  118. merge_job_role_args(siblings.map(&:args))
  119. else
  120. primary.args
  121. end
  122. # Approving one means we approve the entire grouped action.
  123. now = Time.current
  124. primary.update!(args: merged_args)
  125. (siblings - [ primary ]).each do |te|
  126. te.update!(
  127. approved_by: Current.user,
  128. approved_at: now,
  129. status: "success",
  130. finished_at: now,
  131. result: {
  132. deduped: true,
  133. merged_into_tool_execution_id: primary.id
  134. },
  135. error: nil,
  136. metadata: (te.metadata || {}).merge("deduped" => true, "merged_into_tool_execution_id" => primary.id)
  137. )
  138. end
  139. primary
  140. end
  141. def merge_company_args(args_list)
  142. items = []
  143. Array(args_list).each do |args|
  144. args = args.is_a?(Hash) ? args : {}
  145. companies = args["companies"] || args[:companies]
  146. if companies.is_a?(Array)
  147. companies.each { |it| items << (it.is_a?(Hash) ? it : {}) }
  148. else
  149. items << args.slice("company_id", "company_name", "priority").merge(args.slice(:company_id, :company_name, :priority))
  150. end
  151. end
  152. uniq = {}
  153. items.each do |it|
  154. cid = (it["company_id"] || it[:company_id]).to_s.presence
  155. name = (it["company_name"] || it[:company_name]).to_s.strip
  156. key = cid.presence || "name:#{name.downcase}"
  157. next if key.blank? || key == "name:"
  158. pr = it["priority"] || it[:priority]
  159. uniq[key] ||= {}
  160. uniq[key]["company_id"] = cid.to_i if cid.present?
  161. uniq[key]["company_name"] = name if name.present?
  162. uniq[key]["priority"] = pr if pr.present?
  163. end
  164. { "companies" => uniq.values }
  165. end
  166. def merge_job_role_args(args_list)
  167. items = []
  168. Array(args_list).each do |args|
  169. args = args.is_a?(Hash) ? args : {}
  170. roles = args["job_roles"] || args[:job_roles]
  171. if roles.is_a?(Array)
  172. roles.each { |it| items << (it.is_a?(Hash) ? it : {}) }
  173. else
  174. items << args.slice("job_role_id", "job_role_title", "priority").merge(args.slice(:job_role_id, :job_role_title, :priority))
  175. end
  176. end
  177. uniq = {}
  178. items.each do |it|
  179. rid = (it["job_role_id"] || it[:job_role_id]).to_s.presence
  180. title = (it["job_role_title"] || it[:job_role_title]).to_s.strip
  181. key = rid.presence || "title:#{title.downcase}"
  182. next if key.blank? || key == "title:"
  183. pr = it["priority"] || it[:priority]
  184. uniq[key] ||= {}
  185. uniq[key]["job_role_id"] = rid.to_i if rid.present?
  186. uniq[key]["job_role_title"] = title if title.present?
  187. uniq[key]["priority"] = pr if pr.present?
  188. end
  189. { "job_roles" => uniq.values }
  190. end
  191. def switch_originating_message_to_placeholder!(tool_execution)
  192. msg = tool_execution.assistant_message
  193. return if msg.nil?
  194. msg.update!(
  195. content: "Working on it — I’m fetching the latest info now.",
  196. metadata: msg.metadata.merge("pending_tool_followup" => true)
  197. )
  198. end
  199. def validate_tool_execution_args(tool_execution)
  200. tool = ::Assistant::Tool.find_by(tool_key: tool_execution.tool_key)
  201. return [] if tool.nil?
  202. ::Assistant::Tools::ArgSchemaValidator.new(tool.arg_schema).validate(tool_execution.args)
  203. end
  204. def mark_tool_execution_invalid!(tool_execution, errors)
  205. now = Time.current
  206. tool_execution.update!(
  207. status: "error",
  208. finished_at: now,
  209. error: errors.join(", "),
  210. metadata: (tool_execution.metadata || {}).merge("error_type" => "schema_invalid")
  211. )
  212. # Persist tool result so provider histories can safely include tool_result for tool_use_id
  213. ::Assistant::Chat::ToolResultMessagePersister.new(tool_execution: tool_execution).call
  214. rescue StandardError
  215. # best-effort
  216. end
  217. def enqueue_followup_if_ready(tool_execution)
  218. thread = tool_execution.thread
  219. assistant_message_id = tool_execution.assistant_message_id
  220. return if assistant_message_id.blank?
  221. pending = thread.tool_executions.where(assistant_message_id: assistant_message_id, status: %w[proposed queued running]).exists?
  222. return if pending
  223. AssistantToolFollowupJob.perform_later(assistant_message_id)
  224. rescue StandardError
  225. # best-effort
  226. end
  227. def broadcast_tool_proposals(thread)
  228. tool_executions = thread.tool_executions.where(status: %w[proposed queued running]).order(created_at: :asc)
  229. Turbo::StreamsChannel.broadcast_replace_to(
  230. "assistant_thread_#{thread.id}",
  231. target: ActionView::RecordIdentifier.dom_id(thread, :tool_executions),
  232. partial: "assistant/threads/tool_proposals",
  233. locals: { thread: thread, tool_executions: tool_executions }
  234. )
  235. rescue StandardError
  236. # best-effort only
  237. end
  238. end
  239. end

app/controllers/assistant/widgets_controller.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. # GET /assistant/widget
  4. # GET /assistant/widget/threads
  5. # POST /assistant/widget/new_thread
  6. #
  7. # Handles the floating assistant widget that appears across the app.
  8. class WidgetsController < ApplicationController
  9. # GET /assistant/widget
  10. # Shows a thread in the widget. If thread_uuid is provided, shows that thread.
  11. # Otherwise shows the most recent thread (or creates one).
  12. def show
  13. @thread = if params[:thread_uuid].present?
  14. ChatThread.where(user: Current.user).find_by!(uuid: params[:thread_uuid])
  15. else
  16. find_or_create_thread
  17. end
  18. load_thread_data
  19. render layout: false
  20. end
  21. # GET /assistant/widget/threads
  22. # Returns a list of recent threads for the thread switcher dropdown
  23. def threads
  24. @threads = ChatThread.where(user: Current.user)
  25. .order(last_activity_at: :desc, created_at: :desc)
  26. .limit(10)
  27. render layout: false
  28. end
  29. # POST /assistant/widget/new_thread
  30. # Creates a new thread and switches to it in the widget
  31. def new_thread
  32. @thread = ChatThread.create!(
  33. user: Current.user,
  34. title: nil,
  35. status: "open",
  36. last_activity_at: Time.current
  37. )
  38. load_thread_data
  39. render :show, layout: false
  40. end
  41. private
  42. def find_or_create_thread
  43. ChatThread.where(user: Current.user)
  44. .order(last_activity_at: :desc, created_at: :desc)
  45. .first ||
  46. ChatThread.create!(
  47. user: Current.user,
  48. title: nil,
  49. status: "open",
  50. last_activity_at: Time.current
  51. )
  52. end
  53. def load_thread_data
  54. # Tool messages are persisted for provider replay, but should not render in the widget.
  55. @messages = @thread.messages.where(role: %w[user assistant]).order(:created_at)
  56. @tool_executions = @thread.tool_executions.order(created_at: :desc)
  57. @tool_proposals = @tool_executions.select { |te| te.status == "proposed" }
  58. end
  59. end
  60. end

app/controllers/billing/checkouts_controller.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Starts a hosted checkout for a given plan.
  4. class CheckoutsController < ApplicationController
  5. # POST /billing/checkout/:plan_key
  6. def create
  7. plan = Billing::Plan.find_by!(key: params[:plan_key])
  8. # Prepare plan switch (cancel conflicting plans before checkout)
  9. switcher = Billing::PlanSwitcher.new(Current.user)
  10. switch_result = switcher.prepare_switch(plan)
  11. if switch_result[:cancelled_subscription]
  12. Rails.logger.info(
  13. "[billing] checkout cancelled existing subscription for plan switch " \
  14. "user_id=#{Current.user.id} new_plan=#{plan.key}"
  15. )
  16. end
  17. if switch_result[:deactivated_grant]
  18. Rails.logger.info(
  19. "[billing] checkout deactivated existing purchase grant for plan switch " \
  20. "user_id=#{Current.user.id} new_plan=#{plan.key}"
  21. )
  22. end
  23. url = Billing::Providers::LemonSqueezy.new.create_checkout(user: Current.user, plan: plan)
  24. # Important: LemonSqueezy checkout is on a different origin. If this action is
  25. # submitted via Turbo (fetch/XHR), the redirect will be blocked by the browser's
  26. # CORS policy. We also disable Turbo on the checkout forms, but return a 303 to
  27. # encourage a full navigation.
  28. redirect_to url, allow_other_host: true, status: :see_other
  29. rescue ActiveRecord::RecordNotFound
  30. redirect_to settings_path(tab: "billing"), alert: "Plan not found."
  31. rescue => e
  32. ExceptionNotifier.notify(e, context: "payment", severity: "error", user: { id: Current.user&.id, email: Current.user&.email_address }, plan_key: params[:plan_key])
  33. redirect_to settings_path(tab: "billing"), alert: "Could not start checkout. Please try again."
  34. end
  35. end
  36. end

app/controllers/billing/portal_controller.rb

0.0% lines covered

100.0% branches covered

19 relevant lines. 0 lines covered and 19 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Controller for redirecting users to the LemonSqueezy customer portal.
  4. class PortalController < ApplicationController
  5. # GET /billing/portal
  6. #
  7. # Redirects to the LemonSqueezy customer portal for managing payment methods and invoices.
  8. def show
  9. customer = Current.user.billing_customers.find_by(provider: "lemonsqueezy")
  10. unless customer
  11. redirect_to settings_path(tab: "billing", subtab: "billing"),
  12. alert: "No billing account found. Please subscribe to a plan first."
  13. return
  14. end
  15. provider = Billing::Providers::LemonSqueezy.new
  16. url = provider.customer_portal_url(customer: customer)
  17. redirect_to url, allow_other_host: true
  18. rescue StandardError => e
  19. Rails.logger.error("[billing] Failed to get customer portal URL: #{e.message}")
  20. redirect_to settings_path(tab: "billing", subtab: "billing"),
  21. alert: "Failed to access billing portal. Please try again or contact support."
  22. end
  23. end
  24. end

app/controllers/billing/returns_controller.rb

0.0% lines covered

100.0% branches covered

12 relevant lines. 0 lines covered and 12 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Optional return/confirmation page after LemonSqueezy checkout.
  4. #
  5. # Note: subscription activation should still rely on webhooks; this page is
  6. # strictly for UX and support context (order_id/order_identifier).
  7. class ReturnsController < ApplicationController
  8. allow_unauthenticated_access only: [ :show ]
  9. # GET /billing/return
  10. def show
  11. @order_id = params[:order_id]
  12. @order_identifier = params[:order_identifier]
  13. @email = params[:email]
  14. @name = params[:name]
  15. @total = params[:total]
  16. end
  17. end
  18. end

app/controllers/billing/subscriptions_controller.rb

0.0% lines covered

100.0% branches covered

50 relevant lines. 0 lines covered and 50 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Controller for managing subscription actions (cancel/resume).
  4. class SubscriptionsController < ApplicationController
  5. before_action :set_subscription
  6. # POST /billing/subscription/cancel
  7. #
  8. # Sets the subscription to cancel at period end.
  9. def cancel
  10. unless @subscription
  11. redirect_to settings_path(tab: "billing", subtab: "subscription"),
  12. alert: "No active subscription found."
  13. return
  14. end
  15. if @subscription.cancel_at_period_end?
  16. redirect_to settings_path(tab: "billing", subtab: "subscription"),
  17. notice: "Subscription is already set to cancel."
  18. return
  19. end
  20. provider = Billing::Providers::LemonSqueezy.new
  21. provider.cancel_subscription(subscription: @subscription)
  22. redirect_to settings_path(tab: "billing", subtab: "subscription"),
  23. notice: "Your subscription will cancel at the end of the current billing period."
  24. rescue StandardError => e
  25. Rails.logger.error("[billing] Failed to cancel subscription: #{e.message}")
  26. redirect_to settings_path(tab: "billing", subtab: "subscription"),
  27. alert: "Failed to cancel subscription. Please try again or contact support."
  28. end
  29. # POST /billing/subscription/resume
  30. #
  31. # Removes the cancellation from a subscription.
  32. def resume
  33. unless @subscription
  34. redirect_to settings_path(tab: "billing", subtab: "subscription"),
  35. alert: "No active subscription found."
  36. return
  37. end
  38. unless @subscription.cancel_at_period_end?
  39. redirect_to settings_path(tab: "billing", subtab: "subscription"),
  40. notice: "Subscription is not set to cancel."
  41. return
  42. end
  43. provider = Billing::Providers::LemonSqueezy.new
  44. provider.resume_subscription(subscription: @subscription)
  45. redirect_to settings_path(tab: "billing", subtab: "subscription"),
  46. notice: "Your subscription has been resumed and will continue renewing."
  47. rescue StandardError => e
  48. Rails.logger.error("[billing] Failed to resume subscription: #{e.message}")
  49. redirect_to settings_path(tab: "billing", subtab: "subscription"),
  50. alert: "Failed to resume subscription. Please try again or contact support."
  51. end
  52. private
  53. def set_subscription
  54. entitlements = Billing::Entitlements.for(Current.user)
  55. @subscription = entitlements.active_subscription
  56. end
  57. end
  58. end

app/controllers/categories_controller.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for category autocomplete + JSON create.
  3. # Used by the shared autocomplete component.
  4. class CategoriesController < ApplicationController
  5. # GET /categories
  6. def index
  7. @categories = Category.enabled.alphabetical
  8. @categories = @categories.for_kind(params[:kind]) if params[:kind].present?
  9. if params[:q].present?
  10. @categories = @categories.where("name ILIKE ?", "%#{params[:q]}%")
  11. end
  12. @categories = @categories.limit(50)
  13. respond_to do |format|
  14. format.html
  15. format.json { render json: @categories }
  16. end
  17. end
  18. # GET /categories/autocomplete
  19. def autocomplete
  20. query = params[:q].to_s.strip
  21. kind = params[:kind].presence
  22. categories = Category.enabled.alphabetical
  23. categories = categories.for_kind(kind) if kind
  24. categories = if query.present?
  25. categories.where("name ILIKE ?", "%#{query}%").limit(10)
  26. else
  27. categories.limit(10)
  28. end
  29. render json: categories.map { |c| { id: c.id, name: c.name, category: c.kind } }
  30. end
  31. # POST /categories
  32. def create
  33. return head :not_acceptable unless request.format.json?
  34. name = (params[:name] || params.dig(:category, :name))&.strip
  35. kind = (params[:kind] || params.dig(:category, :kind))&.to_s
  36. return render json: { errors: [ "Name is required" ] }, status: :unprocessable_entity if name.blank?
  37. return render json: { errors: [ "Kind is required" ] }, status: :unprocessable_entity if kind.blank?
  38. return render json: { errors: [ "Kind is invalid" ] }, status: :unprocessable_entity unless Category.kinds.key?(kind)
  39. category = Category.where("LOWER(name) = ? AND kind = ?", name.downcase, Category.kinds[kind]).first
  40. if category.nil?
  41. category = Category.new(name: name, kind: kind)
  42. if category.save
  43. render json: { id: category.id, name: category.name }, status: :created
  44. else
  45. render json: { errors: category.errors.full_messages }, status: :unprocessable_entity
  46. end
  47. else
  48. category.update!(disabled_at: nil) if category.disabled?
  49. render json: { id: category.id, name: category.name }, status: :ok
  50. end
  51. end
  52. end

app/controllers/companies_controller.rb

0.0% lines covered

100.0% branches covered

58 relevant lines. 0 lines covered and 58 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing companies
  3. class CompaniesController < ApplicationController
  4. # GET /companies
  5. def index
  6. @companies = Company.enabled.alphabetical
  7. if params[:q].present?
  8. @companies = @companies.where("name ILIKE ?", "%#{params[:q]}%")
  9. end
  10. @companies = @companies.limit(50)
  11. respond_to do |format|
  12. format.html
  13. format.json { render json: @companies }
  14. end
  15. end
  16. # GET /companies/autocomplete
  17. def autocomplete
  18. query = params[:q].to_s.strip
  19. @companies = if query.present?
  20. Company.enabled.where("name ILIKE ?", "%#{query}%")
  21. .alphabetical
  22. .limit(10)
  23. else
  24. Company.enabled.alphabetical.limit(10)
  25. end
  26. render json: @companies.map { |c| { id: c.id, name: c.name, website: c.website } }
  27. end
  28. # POST /companies
  29. def create
  30. # Handle both form params and JSON params (for auto-create)
  31. if request.format.json?
  32. # Auto-create from autocomplete - only name is required
  33. name = (params[:name] || params.dig(:company, :name))&.strip
  34. return render json: { errors: [ "Name is required" ] }, status: :unprocessable_entity if name.blank?
  35. # Find by case-insensitive name
  36. @company = Company.where("LOWER(name) = ?", name.downcase).first
  37. if @company.nil?
  38. # Create new company
  39. @company = Company.new(name: name)
  40. if @company.save
  41. render json: { id: @company.id, name: @company.name }, status: :created
  42. else
  43. render json: { errors: @company.errors.full_messages }, status: :unprocessable_entity
  44. end
  45. else
  46. # If it exists but was disabled, re-enable it
  47. @company.update!(disabled_at: nil) if @company.disabled?
  48. # Company already exists, return it
  49. render json: { id: @company.id, name: @company.name }, status: :ok
  50. end
  51. else
  52. # Regular form submission
  53. @company = Company.new(company_params)
  54. if @company.save
  55. respond_to do |format|
  56. format.html { redirect_to companies_path, notice: "Company created successfully!" }
  57. format.turbo_stream { flash.now[:notice] = "Company created successfully!" }
  58. end
  59. else
  60. respond_to do |format|
  61. format.html { render :new, status: :unprocessable_entity }
  62. end
  63. end
  64. end
  65. end
  66. private
  67. def company_params
  68. params.expect(company: [ :name, :website, :about, :logo_url ])
  69. end
  70. end

app/controllers/company_feedbacks_controller.rb

0.0% lines covered

100.0% branches covered

58 relevant lines. 0 lines covered and 58 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing company feedback for interview applications
  3. class CompanyFeedbacksController < ApplicationController
  4. before_action :set_application
  5. before_action :set_feedback, only: [:show, :edit, :update, :destroy]
  6. # GET /interview_applications/:interview_application_id/company_feedback
  7. def show
  8. end
  9. # GET /interview_applications/:interview_application_id/company_feedback/new
  10. def new
  11. @feedback = @application.build_company_feedback
  12. end
  13. # GET /interview_applications/:interview_application_id/company_feedback/edit
  14. def edit
  15. end
  16. # POST /interview_applications/:interview_application_id/company_feedback
  17. def create
  18. @feedback = @application.build_company_feedback(feedback_params)
  19. if @feedback.save
  20. respond_to do |format|
  21. format.html { redirect_to interview_application_path(@application), notice: "Company feedback added successfully!" }
  22. format.turbo_stream { flash.now[:notice] = "Company feedback added successfully!" }
  23. end
  24. else
  25. render :new, status: :unprocessable_entity
  26. end
  27. end
  28. # PATCH/PUT /interview_applications/:interview_application_id/company_feedback
  29. def update
  30. if @feedback.update(feedback_params)
  31. respond_to do |format|
  32. format.html { redirect_to interview_application_path(@application), notice: "Company feedback updated successfully!" }
  33. format.turbo_stream { flash.now[:notice] = "Company feedback updated successfully!" }
  34. end
  35. else
  36. render :edit, status: :unprocessable_entity
  37. end
  38. end
  39. # DELETE /interview_applications/:interview_application_id/company_feedback
  40. def destroy
  41. @feedback.destroy
  42. respond_to do |format|
  43. format.html { redirect_to interview_application_path(@application), notice: "Company feedback deleted successfully!", status: :see_other }
  44. format.turbo_stream { flash.now[:notice] = "Company feedback deleted successfully!" }
  45. end
  46. end
  47. private
  48. def set_application
  49. @application = Current.user.interview_applications.find(params[:interview_application_id])
  50. rescue ActiveRecord::RecordNotFound
  51. redirect_to interview_applications_path, alert: "Application not found"
  52. end
  53. def set_feedback
  54. @feedback = @application.company_feedback
  55. redirect_to interview_application_path(@application), alert: "Feedback not found" if @feedback.nil?
  56. end
  57. def feedback_params
  58. params.expect(company_feedback: [
  59. :feedback_text,
  60. :received_at,
  61. :rejection_reason,
  62. :next_steps,
  63. :self_reflection
  64. ])
  65. end
  66. end

app/controllers/concerns/authentication.rb

38.1% lines covered

7.14% branches covered

63 relevant lines. 24 lines covered and 39 lines missed.
42 total branches, 3 branches covered and 39 branches missed.
    
  1. 1 module Authentication
  2. 1 extend ActiveSupport::Concern
  3. 1 included do
  4. 1 before_action :resume_session # Always resume session to make authenticated? work in views
  5. 1 before_action :require_authentication
  6. 1 helper_method :authenticated?
  7. end
  8. 1 class_methods do
  9. 1 def allow_unauthenticated_access(**options)
  10. skip_before_action :require_authentication, **options
  11. end
  12. end
  13. 1 private
  14. 1 def authenticated?
  15. resume_session
  16. end
  17. 1 def require_authentication
  18. set_no_cache_headers
  19. resume_session || request_authentication
  20. end
  21. 1 def set_no_cache_headers
  22. response.set_header("Cache-Control", "no-store, private")
  23. end
  24. 1 def resume_session
  25. 7 Current.session ||= find_session_by_cookie
  26. end
  27. 1 def find_session_by_cookie
  28. # Prefer Rails session storage (works even when ActionDispatch::Cookies isn't fully available)
  29. 7 then: 7 else: 0 if respond_to?(:session) && session.respond_to?(:[])
  30. 7 session_id = session[:auth_session_id]
  31. 7 then: 0 else: 7 return Session.find_by(id: session_id) if session_id.present?
  32. end
  33. # Fallback to signed cookie storage when available
  34. 7 then: 0 else: 0 else: 0 then: 7 return nil unless respond_to?(:cookies) && cookies&.respond_to?(:signed)
  35. raw = cookies.signed[:session_id]
  36. session_id = normalize_session_id(raw)
  37. then: 0 else: 0 return nil if session_id.blank?
  38. Session.find_by(id: session_id)
  39. end
  40. # Normalizes the signed cookie payload to a usable session id.
  41. #
  42. # Rails cookie jars may return:
  43. # - a String/Integer session id
  44. # - a metadata Hash (depending on cookie serializer/version)
  45. #
  46. # @param raw [Object]
  47. # @return [String, nil]
  48. 1 def normalize_session_id(raw)
  49. then: 0 else: 0 return nil if raw.blank?
  50. then: 0 else: 0 if raw.is_a?(Hash)
  51. payload = raw["_rails"] || raw[:_rails]
  52. then: 0 else: 0 message = payload.is_a?(Hash) ? (payload["message"] || payload[:message]) : nil
  53. then: 0 else: 0 raw = message if message.present?
  54. end
  55. value = raw.to_s
  56. then: 0 else: 0 return value if value.match?(/\A\d+\z/)
  57. # Some Rails cookie formats base64-encode the message.
  58. then: 0 else: 0 if value.match?(/\A[A-Za-z0-9+\/]+=*\z/)
  59. decoded = Base64.decode64(value).to_s
  60. then: 0 else: 0 return decoded if decoded.match?(/\A\d+\z/)
  61. # Some formats wrap the signed value as JSON metadata:
  62. # {"_rails":{"message":"<base64>","exp":...,"pur":...}}
  63. then: 0 else: 0 if decoded.strip.start_with?("{")
  64. begin
  65. data = JSON.parse(decoded)
  66. payload = data["_rails"] || data.dig("_rails")
  67. then: 0 else: 0 message = payload.is_a?(Hash) ? payload["message"] : nil
  68. then: 0 else: 0 if message.is_a?(String)
  69. inner = Base64.decode64(message).to_s
  70. then: 0 else: 0 return inner if inner.match?(/\A\d+\z/)
  71. end
  72. rescue JSON::ParserError
  73. # ignore
  74. end
  75. end
  76. end
  77. nil
  78. rescue ArgumentError
  79. nil
  80. end
  81. 1 def request_authentication
  82. session[:return_to_after_authenticating] = request.url
  83. redirect_to new_session_path
  84. end
  85. 1 def after_authentication_url
  86. session.delete(:return_to_after_authenticating) || dashboard_path
  87. end
  88. 1 def start_new_session_for(user)
  89. user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |user_session|
  90. Current.session = user_session
  91. # Store session id in Rails session for reliability.
  92. then: 0 else: 0 session[:auth_session_id] = user_session.id if respond_to?(:session) && session.respond_to?(:[]=)
  93. # Also store in a signed cookie when available (helps if session store changes later).
  94. then: 0 else: 0 then: 0 else: 0 if respond_to?(:cookies) && cookies&.respond_to?(:permanent) && cookies.permanent.respond_to?(:signed)
  95. cookies.permanent.signed[:session_id] = user_session.id
  96. end
  97. end
  98. end
  99. 1 def terminate_session
  100. Current.session.destroy
  101. then: 0 else: 0 session.delete(:auth_session_id) if respond_to?(:session) && session.respond_to?(:delete)
  102. then: 0 else: 0 cookies.delete(:session_id) if respond_to?(:cookies) && cookies.respond_to?(:delete)
  103. end
  104. end

app/controllers/concerns/paginatable.rb

100.0% lines covered

100.0% branches covered

4 relevant lines. 4 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Concern for pagination functionality using Pagy
  3. #
  4. # Provides standardized pagination helpers for controllers.
  5. # Include this concern in ApplicationController or individual controllers.
  6. #
  7. # @example
  8. # class UsersController < ApplicationController
  9. # include Paginatable
  10. #
  11. # def index
  12. # @pagy, @users = pagy(User.all)
  13. # end
  14. # end
  15. #
  16. 1 module Paginatable
  17. 1 extend ActiveSupport::Concern
  18. 1 included do
  19. 1 include Pagy::Backend
  20. end
  21. end

app/controllers/dashboard_controller.rb

0.0% lines covered

100.0% branches covered

136 relevant lines. 0 lines covered and 136 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for the user dashboard
  3. #
  4. # Provides a minimalistic overview with quick actions, attention items,
  5. # recent activity, and pipeline summary.
  6. class DashboardController < ApplicationController
  7. # GET /dashboard
  8. #
  9. # Main dashboard view for authenticated users
  10. def index
  11. @user = Current.user
  12. @quick_stats = calculate_quick_stats
  13. @needs_attention = calculate_needs_attention
  14. @recent_activity = recent_activity_feed
  15. @pipeline_summary = pipeline_summary
  16. @upcoming_interviews = upcoming_interviews
  17. end
  18. private
  19. # Calculates quick stats for the dashboard header
  20. #
  21. # @return [Hash] Stats data
  22. def calculate_quick_stats
  23. {
  24. active_applications: Current.user.interview_applications.not_deleted.where(status: :active).count,
  25. total_applications: Current.user.interview_applications.not_deleted.count,
  26. interviews_this_week: interviews_this_week_count,
  27. emails_to_review: Current.user.synced_emails.needs_review.count,
  28. skills_count: Current.user.user_skills.count,
  29. resumes_count: Current.user.user_resumes.count
  30. }
  31. end
  32. # Calculates items that need user attention
  33. #
  34. # @return [Array<Hash>] Attention items with type, count, message, and path
  35. def calculate_needs_attention
  36. items = []
  37. # Signals needing attention
  38. email_count = Current.user.synced_emails.needs_review.count
  39. if email_count > 0
  40. items << {
  41. type: :signals,
  42. count: email_count,
  43. message: "#{email_count} #{'signal'.pluralize(email_count)} need attention",
  44. path: signals_path,
  45. icon: "bolt",
  46. color: "amber"
  47. }
  48. end
  49. # Upcoming interviews this week
  50. interview_count = interviews_this_week_count
  51. if interview_count > 0
  52. items << {
  53. type: :interviews,
  54. count: interview_count,
  55. message: "#{interview_count} #{'interview'.pluralize(interview_count)} this week",
  56. path: interview_applications_path,
  57. icon: "calendar",
  58. color: "blue"
  59. }
  60. end
  61. # Stale applications (no activity in 14+ days)
  62. stale_count = stale_applications_count
  63. if stale_count > 0
  64. items << {
  65. type: :stale,
  66. count: stale_count,
  67. message: "#{stale_count} #{'application'.pluralize(stale_count)} need follow-up",
  68. path: interview_applications_path,
  69. icon: "clock",
  70. color: "orange"
  71. }
  72. end
  73. # Actionable opportunities (new or reviewing)
  74. opportunity_count = Current.user.opportunities.actionable.count
  75. if opportunity_count > 0
  76. items << {
  77. type: :opportunities,
  78. count: opportunity_count,
  79. message: "#{opportunity_count} new #{'opportunity'.pluralize(opportunity_count)}",
  80. path: opportunities_path,
  81. icon: "sparkles",
  82. color: "purple"
  83. }
  84. end
  85. items
  86. end
  87. # Builds recent activity feed
  88. #
  89. # @return [Array<Hash>] Recent activity items
  90. def recent_activity_feed
  91. activities = []
  92. # Recent applications (last 5)
  93. Current.user.interview_applications.not_deleted.recent.limit(5).each do |app|
  94. activities << {
  95. type: :application,
  96. title: "Applied to #{app.job_role.title}",
  97. subtitle: app.company.name,
  98. timestamp: app.applied_at || app.created_at,
  99. path: interview_application_path(app),
  100. icon: "briefcase"
  101. }
  102. end
  103. # Recent interview rounds (last 5)
  104. Current.user.interview_rounds
  105. .joins(:interview_application)
  106. .where(interview_applications: { user_id: Current.user.id, deleted_at: nil })
  107. .order(created_at: :desc)
  108. .limit(5)
  109. .includes(interview_application: [ :company, :job_role ])
  110. .each do |round|
  111. app = round.interview_application
  112. activities << {
  113. type: :interview,
  114. title: "#{round.stage_display_name} interview",
  115. subtitle: "#{app.company.name} - #{app.job_role.title}",
  116. timestamp: round.scheduled_at || round.created_at,
  117. path: interview_application_path(app),
  118. icon: "video-camera"
  119. }
  120. end
  121. # Sort by timestamp and take top 5
  122. activities.sort_by { |a| a[:timestamp] || Time.at(0) }.reverse.first(5)
  123. end
  124. # Calculates pipeline summary by stage
  125. #
  126. # @return [Hash] Pipeline counts by stage
  127. def pipeline_summary
  128. base = Current.user.interview_applications.not_deleted.where(status: :active)
  129. {
  130. applied: base.where(pipeline_stage: :applied).count,
  131. screening: base.where(pipeline_stage: :screening).count,
  132. interviewing: base.where(pipeline_stage: :interviewing).count,
  133. offer: base.where(pipeline_stage: :offer).count,
  134. closed: base.where(pipeline_stage: :closed).count,
  135. total: base.count
  136. }
  137. end
  138. # Returns upcoming interviews (next 7 days)
  139. #
  140. # @return [ActiveRecord::Relation]
  141. def upcoming_interviews
  142. Current.user.interview_rounds
  143. .joins(:interview_application)
  144. .where(interview_applications: { user_id: Current.user.id, deleted_at: nil })
  145. .where("scheduled_at > ? AND scheduled_at < ?", Time.current, 7.days.from_now)
  146. .where(completed_at: nil)
  147. .order(scheduled_at: :asc)
  148. .includes(interview_application: [ :company, :job_role ])
  149. .limit(5)
  150. end
  151. # Counts interviews scheduled this week
  152. #
  153. # @return [Integer]
  154. def interviews_this_week_count
  155. Current.user.interview_rounds
  156. .joins(:interview_application)
  157. .where(interview_applications: { user_id: Current.user.id, deleted_at: nil })
  158. .where("scheduled_at >= ? AND scheduled_at <= ?", Time.current.beginning_of_week, Time.current.end_of_week)
  159. .where(completed_at: nil)
  160. .count
  161. end
  162. # Counts stale applications (no activity in 14+ days)
  163. #
  164. # @return [Integer]
  165. def stale_applications_count
  166. Current.user.interview_applications
  167. .not_deleted
  168. .where(status: :active)
  169. .where("updated_at < ?", 14.days.ago)
  170. .count
  171. end
  172. end

app/controllers/email_verifications_controller.rb

0.0% lines covered

100.0% branches covered

32 relevant lines. 0 lines covered and 32 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class EmailVerificationsController < ApplicationController
  2. allow_unauthenticated_access
  3. before_action :set_user_by_token, only: :show
  4. rate_limit to: 5, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
  5. layout "authentication"
  6. # GET /email_verification/new
  7. # Show form to request verification email resend
  8. def new
  9. @email_address = params[:email_address]
  10. end
  11. # GET /email_verification/:token
  12. # Verify user's email address
  13. def show
  14. if @user.verify_email!
  15. # Send welcome email
  16. UserMailer.welcome(@user).deliver_later
  17. redirect_to new_session_path, notice: "Email verified! You can now sign in."
  18. else
  19. redirect_to new_session_path, alert: "Unable to verify email. Please try again."
  20. end
  21. end
  22. # POST /email_verification
  23. # Resend verification email
  24. def create
  25. if user = User.find_by(email_address: params[:email_address])
  26. unless user.email_verified?
  27. UserMailer.verify_email(user).deliver_later
  28. end
  29. end
  30. redirect_to new_session_path, notice: "Verification email sent (if user exists and is not verified)."
  31. end
  32. private
  33. def set_user_by_token
  34. @user = User.find_by_token_for(:email_verification, params[:token])
  35. unless @user
  36. redirect_to new_session_path, alert: "Email verification link is invalid or has expired."
  37. end
  38. end
  39. end

app/controllers/inbox_controller.rb

0.0% lines covered

100.0% branches covered

243 relevant lines. 0 lines covered and 243 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for the intelligent inbox view
  3. # Displays synced emails grouped by application with smart filtering
  4. # Supports split-pane layout with Turbo Frames
  5. class InboxController < ApplicationController
  6. before_action :set_synced_email, only: [ :show, :match_application, :ignore ]
  7. # GET /inbox
  8. #
  9. # Main inbox view with split-pane layout
  10. def index
  11. @emails = current_user_emails
  12. .includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
  13. .order(email_date: :desc)
  14. # Apply relevance filter (default to relevant emails only)
  15. @current_relevance = params[:relevance] || "relevant"
  16. @emails = filter_by_relevance(@emails)
  17. # Apply other filters
  18. @emails = filter_by_type(@emails)
  19. @emails = filter_by_status(@emails)
  20. @emails = filter_by_company(@emails)
  21. @emails = search_emails(@emails)
  22. # Group emails by thread for display (showing latest in each thread)
  23. @grouped_emails = group_emails_by_application(@emails)
  24. # Get unmatched emails grouped by thread (only latest email per thread)
  25. unmatched_by_thread = group_emails_by_thread(@emails.unmatched)
  26. @pagy_unmatched, @unmatched_emails = pagy_array(unmatched_by_thread, limit: 15, page_param: :unmatched_page)
  27. # Load filter options
  28. @email_types = SyncedEmail::EMAIL_TYPES
  29. @companies = Company.joins(:interview_applications)
  30. .where(interview_applications: { user_id: Current.user.id })
  31. .distinct
  32. .alphabetical
  33. # Calculate counts for relevance tabs
  34. @relevance_counts = calculate_relevance_counts
  35. # If email_id param, pre-select that email
  36. @selected_email = current_user_emails.find_by(id: params[:email_id]) if params[:email_id]
  37. # Respond to turbo frame requests for email_list (search/filter without full page reload)
  38. respond_to do |format|
  39. format.html do
  40. if turbo_frame_request_id == "email_list"
  41. render inline: <<~ERB, locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @selected_email&.id }
  42. <%= turbo_frame_tag "email_list", class: "flex-1 overflow-y-auto" do %>
  43. <%= render "inbox/email_list", grouped_emails: grouped_emails, unmatched_emails: unmatched_emails, pagy_unmatched: pagy_unmatched, selected_email_id: selected_email_id %>
  44. <% end %>
  45. ERB
  46. else
  47. render :index
  48. end
  49. end
  50. end
  51. end
  52. # GET /inbox/:id
  53. #
  54. # Show email detail - responds to Turbo Frame for split-pane
  55. def show
  56. @application = @email.interview_application
  57. @thread_emails = @email.thread_emails.includes(:email_sender)
  58. respond_to do |format|
  59. format.html do
  60. # Full page render for direct access or mobile
  61. render :show
  62. end
  63. format.turbo_stream do
  64. # Turbo Frame update for split-pane
  65. render turbo_stream: turbo_stream.update(
  66. "email_detail",
  67. partial: "inbox/detail_panel",
  68. locals: { email: @email, thread_emails: @thread_emails, application: @application }
  69. )
  70. end
  71. end
  72. end
  73. # PATCH /inbox/:id/match_application
  74. #
  75. # Match email to an interview application
  76. def match_application
  77. application = Current.user.interview_applications.find_by(id: params[:application_id])
  78. if application && @email.match_to_application!(application)
  79. # Also match other emails in the same thread
  80. match_thread_emails(application) if @email.thread_id.present?
  81. respond_to do |format|
  82. format.html { redirect_to inbox_index_path, notice: "Email matched to #{application.company.name}." }
  83. format.turbo_stream do
  84. @thread_emails = @email.thread_emails.includes(:email_sender)
  85. reload_email_list_data
  86. render turbo_stream: [
  87. turbo_stream.update("email_detail",
  88. partial: "inbox/detail_panel",
  89. locals: { email: @email, thread_emails: @thread_emails, application: application }
  90. ),
  91. turbo_stream.update("email_list",
  92. partial: "inbox/email_list",
  93. locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @email.id }
  94. ),
  95. turbo_stream.update("email_stats",
  96. html: email_stats_html
  97. )
  98. ]
  99. end
  100. format.json { render json: { success: true, application_id: application.id } }
  101. end
  102. else
  103. respond_to do |format|
  104. format.html { redirect_to inbox_index_path, alert: "Could not match email to application." }
  105. format.turbo_stream do
  106. render turbo_stream: turbo_stream.update(
  107. "email_detail",
  108. html: "<div class='p-4 text-red-600'>Could not match email</div>"
  109. )
  110. end
  111. format.json { render json: { success: false }, status: :unprocessable_entity }
  112. end
  113. end
  114. end
  115. # PATCH /inbox/:id/ignore
  116. #
  117. # Mark email as not interview-related
  118. def ignore
  119. if @email.ignore!
  120. respond_to do |format|
  121. format.html { redirect_to inbox_index_path, notice: "Email marked as not interview-related." }
  122. format.turbo_stream do
  123. reload_email_list_data
  124. render turbo_stream: [
  125. turbo_stream.update("email_detail",
  126. partial: "inbox/empty_state"
  127. ),
  128. turbo_stream.update("email_list",
  129. partial: "inbox/email_list",
  130. locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: nil }
  131. ),
  132. turbo_stream.update("email_stats",
  133. html: email_stats_html
  134. )
  135. ]
  136. end
  137. format.json { render json: { success: true } }
  138. end
  139. else
  140. respond_to do |format|
  141. format.html { redirect_to inbox_index_path, alert: "Could not ignore email." }
  142. format.turbo_stream do
  143. @thread_emails = @email.thread_emails.includes(:email_sender)
  144. render turbo_stream: turbo_stream.update(
  145. "email_detail",
  146. partial: "inbox/detail_panel",
  147. locals: { email: @email, thread_emails: @thread_emails, application: @email.interview_application }
  148. )
  149. end
  150. format.json { render json: { success: false }, status: :unprocessable_entity }
  151. end
  152. end
  153. end
  154. private
  155. # Sets the email for member actions
  156. #
  157. # @return [SyncedEmail]
  158. def set_synced_email
  159. @email = current_user_emails.find(params[:id])
  160. rescue ActiveRecord::RecordNotFound
  161. respond_to do |format|
  162. format.html { redirect_to inbox_index_path, alert: "Email not found." }
  163. format.turbo_stream do
  164. render turbo_stream: turbo_stream.update(
  165. "email_detail",
  166. partial: "inbox/empty_state"
  167. )
  168. end
  169. end
  170. end
  171. # Returns the current user's synced emails
  172. #
  173. # @return [ActiveRecord::Relation]
  174. def current_user_emails
  175. Current.user.synced_emails
  176. end
  177. # Filters emails by relevance (all, relevant, interviews, opportunities)
  178. #
  179. # @param emails [ActiveRecord::Relation]
  180. # @return [ActiveRecord::Relation]
  181. def filter_by_relevance(emails)
  182. case @current_relevance
  183. when "all"
  184. emails.visible # Excludes ignored and auto_ignored
  185. when "interviews"
  186. emails.interview_related.visible
  187. when "opportunities"
  188. emails.potential_opportunities.visible
  189. else # "relevant" (default)
  190. emails.relevant
  191. end
  192. end
  193. # Calculates counts for relevance filter tabs
  194. #
  195. # @return [Hash] Counts by relevance type
  196. def calculate_relevance_counts
  197. base = current_user_emails
  198. {
  199. all: base.visible.count,
  200. relevant: base.relevant.count,
  201. interviews: base.interview_related.visible.count,
  202. opportunities: base.potential_opportunities.visible.count
  203. }
  204. end
  205. # Filters emails by type
  206. #
  207. # @param emails [ActiveRecord::Relation]
  208. # @return [ActiveRecord::Relation]
  209. def filter_by_type(emails)
  210. return emails unless params[:type].present?
  211. emails.by_type(params[:type])
  212. end
  213. # Filters emails by status (matched/unmatched/all)
  214. #
  215. # @param emails [ActiveRecord::Relation]
  216. # @return [ActiveRecord::Relation]
  217. def filter_by_status(emails)
  218. case params[:status]
  219. when "matched"
  220. emails.matched
  221. when "unmatched"
  222. emails.unmatched
  223. when "pending"
  224. emails.pending
  225. when "ignored"
  226. emails.ignored
  227. else
  228. emails
  229. end
  230. end
  231. # Filters emails by company
  232. #
  233. # @param emails [ActiveRecord::Relation]
  234. # @return [ActiveRecord::Relation]
  235. def filter_by_company(emails)
  236. return emails unless params[:company_id].present?
  237. company = Company.find_by(id: params[:company_id])
  238. return emails unless company
  239. application_ids = Current.user.interview_applications
  240. .where(company: company)
  241. .pluck(:id)
  242. emails.where(interview_application_id: application_ids)
  243. end
  244. # Searches emails by query
  245. #
  246. # @param emails [ActiveRecord::Relation]
  247. # @return [ActiveRecord::Relation]
  248. def search_emails(emails)
  249. return emails unless params[:q].present?
  250. query = "%#{params[:q]}%"
  251. emails.where(
  252. "subject ILIKE :q OR from_email ILIKE :q OR from_name ILIKE :q OR snippet ILIKE :q",
  253. q: query
  254. )
  255. end
  256. # Groups emails by their associated application
  257. # Returns the latest email from each thread grouped by application
  258. #
  259. # @param emails [ActiveRecord::Relation]
  260. # @return [Hash]
  261. def group_emails_by_application(emails)
  262. # Get unique threads, keeping only the latest email from each thread
  263. unique_threads = {}
  264. emails.matched.each do |email|
  265. thread_key = email.thread_id || email.id
  266. if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
  267. unique_threads[thread_key] = email
  268. end
  269. end
  270. # Group by application
  271. unique_threads.values
  272. .group_by(&:interview_application)
  273. .transform_values { |app_emails| app_emails.sort_by { |e| e.email_date || e.created_at }.reverse }
  274. .sort_by { |app, _| app&.company&.name || "" }
  275. .to_h
  276. end
  277. # Groups emails by thread, keeping only the latest email from each thread
  278. #
  279. # @param emails [ActiveRecord::Relation]
  280. # @return [Array<SyncedEmail>]
  281. def group_emails_by_thread(emails)
  282. unique_threads = {}
  283. emails.each do |email|
  284. thread_key = email.thread_id.presence || "single_#{email.id}"
  285. if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
  286. unique_threads[thread_key] = email
  287. end
  288. end
  289. unique_threads.values.sort_by { |e| e.email_date || e.created_at }.reverse
  290. end
  291. # Matches all emails in the same thread to an application
  292. #
  293. # @param application [InterviewApplication]
  294. # @return [void]
  295. def match_thread_emails(application)
  296. current_user_emails
  297. .where(thread_id: @email.thread_id)
  298. .where.not(id: @email.id)
  299. .update_all(interview_application_id: application.id, status: :processed)
  300. end
  301. # Reloads the email list data for Turbo Stream updates
  302. #
  303. # @return [void]
  304. def reload_email_list_data
  305. emails = current_user_emails
  306. .includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
  307. .order(email_date: :desc)
  308. @grouped_emails = group_emails_by_application(emails)
  309. unmatched_by_thread = group_emails_by_thread(emails.unmatched)
  310. @pagy_unmatched, @unmatched_emails = pagy_array(unmatched_by_thread, limit: 15, page_param: :unmatched_page)
  311. end
  312. # Returns HTML for the email stats footer
  313. #
  314. # @return [String]
  315. def email_stats_html
  316. needs_review = Current.user.synced_emails.unmatched.count
  317. matched = Current.user.synced_emails.matched.count
  318. "<span>#{needs_review} needs review</span><span>#{matched} matched</span>"
  319. end
  320. end

app/controllers/internal/developer/ai/base_controller.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ai
  5. # Base controller for the AI Portal
  6. class BaseController < Internal::Developer::BaseController
  7. helper_method :current_portal
  8. private
  9. def current_portal
  10. :ai
  11. end
  12. def portal_resources
  13. Admin::Base::Resource.resources_for_portal(:ai)
  14. end
  15. end
  16. end
  17. end
  18. end

app/controllers/internal/developer/ai/dashboard_controller.rb

0.0% lines covered

100.0% branches covered

83 relevant lines. 0 lines covered and 83 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ai
  5. # Dashboard for the AI Portal
  6. class DashboardController < BaseController
  7. before_action :load_resources!
  8. # GET /internal/developer/ai
  9. def index
  10. @resources_by_section = build_resources_by_section
  11. @stats = calculate_portal_stats
  12. @health = calculate_health_metrics
  13. @charts = build_chart_data
  14. @recent = build_recent_activity
  15. end
  16. private
  17. def build_resources_by_section
  18. resources = {}
  19. portal_resources.each do |resource|
  20. section = resource.section_name || :general
  21. resources[section] ||= []
  22. resources[section] << resource
  23. end
  24. resources
  25. end
  26. def calculate_portal_stats
  27. {
  28. total_resources: portal_resources.count,
  29. llm_prompts: ::Ai::LlmPrompt.count,
  30. active_prompts: ::Ai::LlmPrompt.where(active: true).count,
  31. provider_configs: ::LlmProviderConfig.count,
  32. enabled_providers: ::LlmProviderConfig.where(enabled: true).count,
  33. api_logs: ::Ai::LlmApiLog.count
  34. }
  35. end
  36. def calculate_health_metrics
  37. recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
  38. total = recent_logs.count
  39. successful = recent_logs.where(status: :success).count
  40. failed = recent_logs.where(status: :failed).count
  41. avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
  42. total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
  43. total_cost = (total_cost_cents / 100.0).round(2)
  44. total_tokens = recent_logs.sum(:total_tokens) || 0
  45. success_rate = total > 0 ? (successful.to_f / total * 100).round : 100
  46. status = if total > 10 && success_rate < 80
  47. :critical
  48. elsif total > 10 && success_rate < 95
  49. :degraded
  50. else
  51. :healthy
  52. end
  53. {
  54. status: status,
  55. total: total,
  56. successful: successful,
  57. failed: failed,
  58. success_rate: success_rate,
  59. avg_latency: avg_latency,
  60. total_cost: total_cost,
  61. total_tokens: total_tokens
  62. }
  63. end
  64. def build_chart_data
  65. # Last 7 days of API calls
  66. api_calls_by_day = (0..6).map do |i|
  67. date = i.days.ago.to_date
  68. count = ::Ai::LlmApiLog.where(created_at: date.beginning_of_day..date.end_of_day).count
  69. { label: date.strftime("%a"), value: count }
  70. end.reverse
  71. # Cost by day (in cents)
  72. cost_by_day = (0..6).map do |i|
  73. date = i.days.ago.to_date
  74. cost_cents = ::Ai::LlmApiLog.where(created_at: date.beginning_of_day..date.end_of_day).sum(:estimated_cost_cents) || 0
  75. { label: date.strftime("%a"), value: cost_cents }
  76. end.reverse
  77. { api_calls: api_calls_by_day, cost: cost_by_day }
  78. end
  79. def build_recent_activity
  80. {
  81. api_logs: ::Ai::LlmApiLog.order(created_at: :desc).limit(5),
  82. prompts: ::Ai::LlmPrompt.order(updated_at: :desc).limit(5)
  83. }
  84. end
  85. end
  86. end
  87. end
  88. end

app/controllers/internal/developer/ai/llm_api_logs_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ai
  5. class LlmApiLogsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::LlmApiLogResource
  9. end
  10. def current_portal
  11. :ai
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ai/llm_prompts_controller.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ai
  5. # LlmPrompts controller for the AI Portal
  6. class LlmPromptsController < Internal::Developer::ResourcesController
  7. # POST /internal/developer/ai/llm_prompts/:id/activate
  8. def activate
  9. if @resource.respond_to?(:activate!) && @resource.activate!
  10. redirect_to resource_url(@resource), notice: "Prompt activated."
  11. else
  12. redirect_to resource_url(@resource), alert: "Failed to activate prompt."
  13. end
  14. rescue => e
  15. redirect_to resource_url(@resource), alert: e.message
  16. end
  17. # POST /internal/developer/ai/llm_prompts/:id/duplicate
  18. def duplicate
  19. new_prompt = @resource.dup
  20. new_prompt.name = "#{@resource.name} (Copy)"
  21. new_prompt.active = false
  22. new_prompt.version = (@resource.version || 1) + 1
  23. if new_prompt.save
  24. redirect_to resource_url(new_prompt), notice: "Prompt duplicated successfully."
  25. else
  26. redirect_to resource_url(@resource), alert: "Failed to duplicate prompt."
  27. end
  28. end
  29. private
  30. def current_portal
  31. :ai
  32. end
  33. def resource_config
  34. Admin::Resources::LlmPromptResource
  35. end
  36. end
  37. end
  38. end
  39. end

app/controllers/internal/developer/ai/llm_provider_configs_controller.rb

0.0% lines covered

100.0% branches covered

36 relevant lines. 0 lines covered and 36 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ai
  5. class LlmProviderConfigsController < Internal::Developer::ResourcesController
  6. # POST /internal/developer/ai/llm_provider_configs/:id/toggle
  7. def toggle
  8. @resource.update!(enabled: !@resource.enabled)
  9. respond_to do |format|
  10. format.turbo_stream do
  11. render turbo_stream: turbo_stream.replace(
  12. dom_id(@resource, :toggle),
  13. partial: "internal/developer/shared/toggle_cell",
  14. locals: { record: @resource, field: :enabled }
  15. )
  16. end
  17. format.html { redirect_to resource_url(@resource), notice: "Provider #{@resource.enabled? ? 'enabled' : 'disabled'}." }
  18. end
  19. end
  20. # POST /internal/developer/ai/llm_provider_configs/:id/enable
  21. def enable
  22. @resource.update!(enabled: true)
  23. redirect_to resource_url(@resource), notice: "Provider enabled."
  24. end
  25. # POST /internal/developer/ai/llm_provider_configs/:id/disable
  26. def disable
  27. @resource.update!(enabled: false)
  28. redirect_to resource_url(@resource), notice: "Provider disabled."
  29. end
  30. private
  31. def resource_config
  32. Admin::Resources::LlmProviderConfigResource
  33. end
  34. def current_portal
  35. :ai
  36. end
  37. end
  38. end
  39. end
  40. end

app/controllers/internal/developer/assistant/base_controller.rb

0.0% lines covered

100.0% branches covered

18 relevant lines. 0 lines covered and 18 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. # Base controller for the Assistant Portal
  6. class BaseController < Internal::Developer::BaseController
  7. helper_method :current_portal
  8. private
  9. def current_portal
  10. :assistant
  11. end
  12. def portal_resources
  13. # Include both :ai and :assistant portal resources with assistant section
  14. Admin::Base::Resource.registered_resources.select do |r|
  15. r.section_name == :assistant
  16. end
  17. end
  18. end
  19. end
  20. end
  21. end

app/controllers/internal/developer/assistant/dashboard_controller.rb

0.0% lines covered

100.0% branches covered

83 relevant lines. 0 lines covered and 83 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. # Dashboard for the Assistant Portal
  6. class DashboardController < BaseController
  7. before_action :load_resources!
  8. # GET /internal/developer/assistant
  9. def index
  10. @resources_by_section = build_resources_by_section
  11. @stats = calculate_portal_stats
  12. @health = calculate_health_metrics
  13. @charts = build_chart_data
  14. @recent = build_recent_activity
  15. end
  16. private
  17. def build_resources_by_section
  18. resources = {}
  19. portal_resources.each do |resource|
  20. section = resource.section_name || :general
  21. resources[section] ||= []
  22. resources[section] << resource
  23. end
  24. resources
  25. end
  26. def calculate_portal_stats
  27. {
  28. total_resources: portal_resources.count,
  29. threads: ::Assistant::ChatThread.count,
  30. open_threads: ::Assistant::ChatThread.where(status: "open").count,
  31. tool_executions: ::Assistant::ToolExecution.count,
  32. pending_approvals: ::Assistant::ToolExecution.where(status: :pending_approval).count,
  33. tools: ::Assistant::Tool.count,
  34. active_tools: ::Assistant::Tool.where(enabled: true).count,
  35. user_memories: ::Assistant::Memory::UserMemory.count
  36. }
  37. end
  38. def calculate_health_metrics
  39. recent_threads = ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago)
  40. recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
  41. total_executions = recent_executions.count
  42. successful = recent_executions.where(status: :completed).count
  43. failed = recent_executions.where(status: :failed).count
  44. pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
  45. success_rate = total_executions > 0 ? (successful.to_f / total_executions * 100).round : 100
  46. status = if pending > 20 || (total_executions > 10 && success_rate < 70)
  47. :degraded
  48. elsif failed > 10
  49. :critical
  50. else
  51. :healthy
  52. end
  53. {
  54. status: status,
  55. threads_24h: recent_threads.count,
  56. executions_24h: total_executions,
  57. successful: successful,
  58. failed: failed,
  59. pending: pending,
  60. success_rate: success_rate
  61. }
  62. end
  63. def build_chart_data
  64. # Last 7 days of threads
  65. threads_by_day = (0..6).map do |i|
  66. date = i.days.ago.to_date
  67. count = ::Assistant::ChatThread.where(created_at: date.beginning_of_day..date.end_of_day).count
  68. { label: date.strftime("%a"), value: count }
  69. end.reverse
  70. # Last 7 days of tool executions
  71. executions_by_day = (0..6).map do |i|
  72. date = i.days.ago.to_date
  73. count = ::Assistant::ToolExecution.where(created_at: date.beginning_of_day..date.end_of_day).count
  74. { label: date.strftime("%a"), value: count }
  75. end.reverse
  76. { threads: threads_by_day, executions: executions_by_day }
  77. end
  78. def build_recent_activity
  79. {
  80. threads: ::Assistant::ChatThread.includes(:user).order(created_at: :desc).limit(5),
  81. executions: ::Assistant::ToolExecution.order(created_at: :desc).limit(5),
  82. pending_approvals: ::Assistant::ToolExecution.where(status: :pending_approval).order(created_at: :desc).limit(5)
  83. }
  84. end
  85. end
  86. end
  87. end
  88. end

app/controllers/internal/developer/assistant/events_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. class EventsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::AssistantEventResource
  9. end
  10. def current_portal
  11. :assistant
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/assistant/memory_proposals_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. class MemoryProposalsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::AssistantMemoryProposalResource
  9. end
  10. def current_portal
  11. :assistant
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/assistant/thread_summaries_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. class ThreadSummariesController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::AssistantThreadSummaryResource
  9. end
  10. def current_portal
  11. :assistant
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/assistant/threads_controller.rb

0.0% lines covered

100.0% branches covered

20 relevant lines. 0 lines covered and 20 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. # Threads controller for the Assistant Portal
  6. class ThreadsController < Internal::Developer::ResourcesController
  7. def export
  8. respond_to do |format|
  9. format.json { render json: @resource }
  10. end
  11. end
  12. private
  13. def current_portal
  14. :assistant
  15. end
  16. def resource_config
  17. Admin::Resources::AssistantThreadResource
  18. end
  19. end
  20. end
  21. end
  22. end

app/controllers/internal/developer/assistant/tool_executions_controller.rb

0.0% lines covered

100.0% branches covered

65 relevant lines. 0 lines covered and 65 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. # ToolExecutions controller for the Assistant Portal
  6. class ToolExecutionsController < Internal::Developer::ResourcesController
  7. # POST /internal/developer/assistant/tool_executions/:id/approve
  8. def approve
  9. actor = admin_suite_actor
  10. unless actor.is_a?(User)
  11. redirect_to resource_url(@resource), alert: "Approval requires an authenticated user actor."
  12. return
  13. end
  14. if @resource.requires_confirmation && @resource.approved_by_id.nil?
  15. @resource.update!(approved_by: actor, approved_at: Time.current)
  16. redirect_to resource_url(@resource), notice: "Tool execution approved."
  17. else
  18. redirect_to resource_url(@resource), alert: "Cannot approve this tool execution."
  19. end
  20. end
  21. # POST /internal/developer/assistant/tool_executions/:id/enqueue
  22. def enqueue
  23. if @resource.status == "proposed" && (!@resource.requires_confirmation || @resource.approved_by_id.present?)
  24. @resource.update!(status: "queued")
  25. redirect_to resource_url(@resource), notice: "Tool execution enqueued."
  26. else
  27. redirect_to resource_url(@resource), alert: "Cannot enqueue this tool execution."
  28. end
  29. end
  30. # POST /internal/developer/assistant/tool_executions/:id/replay
  31. def replay
  32. if %w[success error].include?(@resource.status)
  33. redirect_to resource_url(@resource), notice: "Tool execution replayed."
  34. else
  35. redirect_to resource_url(@resource), alert: "Cannot replay this tool execution."
  36. end
  37. end
  38. # POST /internal/developer/assistant/tool_executions/bulk_approve
  39. def bulk_approve
  40. ids = params[:ids] || []
  41. actor = admin_suite_actor
  42. unless actor.is_a?(User)
  43. redirect_to collection_url, alert: "Bulk approval requires an authenticated user actor."
  44. return
  45. end
  46. resource_class.where(id: ids, status: "proposed")
  47. .where(requires_confirmation: true, approved_by_id: nil)
  48. .update_all(approved_by_id: actor.id, approved_at: Time.current)
  49. redirect_to collection_url, notice: "Selected tool executions approved."
  50. end
  51. # POST /internal/developer/assistant/tool_executions/bulk_enqueue
  52. def bulk_enqueue
  53. ids = params[:ids] || []
  54. resource_class.where(id: ids, status: "proposed").update_all(status: "queued")
  55. redirect_to collection_url, notice: "Selected tool executions enqueued."
  56. end
  57. def export
  58. respond_to do |format|
  59. format.json { render json: @resource }
  60. end
  61. end
  62. private
  63. def current_portal
  64. :assistant
  65. end
  66. def resource_config
  67. Admin::Resources::AssistantToolExecutionResource
  68. end
  69. end
  70. end
  71. end
  72. end

app/controllers/internal/developer/assistant/tools_controller.rb

0.0% lines covered

100.0% branches covered

36 relevant lines. 0 lines covered and 36 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. class ToolsController < Internal::Developer::ResourcesController
  6. # POST /internal/developer/assistant/tools/:id/toggle
  7. def toggle
  8. @resource.update!(enabled: !@resource.enabled)
  9. respond_to do |format|
  10. format.turbo_stream do
  11. render turbo_stream: turbo_stream.replace(
  12. dom_id(@resource, :toggle),
  13. partial: "internal/developer/shared/toggle_cell",
  14. locals: { record: @resource, field: :enabled }
  15. )
  16. end
  17. format.html { redirect_to resource_url(@resource), notice: "Tool #{@resource.enabled? ? 'enabled' : 'disabled'}." }
  18. end
  19. end
  20. # POST /internal/developer/assistant/tools/:id/enable
  21. def enable
  22. @resource.update!(enabled: true)
  23. redirect_to resource_url(@resource), notice: "Tool enabled."
  24. end
  25. # POST /internal/developer/assistant/tools/:id/disable
  26. def disable
  27. @resource.update!(enabled: false)
  28. redirect_to resource_url(@resource), notice: "Tool disabled."
  29. end
  30. private
  31. def resource_config
  32. Admin::Resources::AssistantToolResource
  33. end
  34. def current_portal
  35. :assistant
  36. end
  37. end
  38. end
  39. end
  40. end

app/controllers/internal/developer/assistant/turns_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. class TurnsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::AssistantTurnResource
  9. end
  10. def current_portal
  11. :assistant
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/assistant/user_memories_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Assistant
  5. class UserMemoriesController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::AssistantUserMemoryResource
  9. end
  10. def current_portal
  11. :assistant
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/base_controller.rb

0.0% lines covered

100.0% branches covered

108 relevant lines. 0 lines covered and 108 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. # Base controller for the new admin framework at /internal/developer
  5. #
  6. # Provides common functionality for all developer portal controllers:
  7. # - Developer authentication via TechWright SSO
  8. # - Layout configuration
  9. # - Resource discovery and resolution
  10. class BaseController < ApplicationController
  11. include ActionView::RecordIdentifier
  12. # Skip regular user authentication - developers use TechWright SSO
  13. allow_unauthenticated_access
  14. before_action :require_admin!
  15. layout "developer"
  16. helper Internal::Developer::BaseHelper
  17. helper_method :current_portal, :navigation_items, :resource_config, :current_developer, :admin_suite_actor
  18. private
  19. # Requires developer authentication via TechWright SSO
  20. #
  21. # @return [void]
  22. def require_admin!
  23. if defined?(AdminSuite) && AdminSuite.config.authenticate.present?
  24. AdminSuite.config.authenticate.call(self)
  25. return
  26. end
  27. unless developer_authenticated?
  28. redirect_to internal_developer_login_path
  29. end
  30. end
  31. # Checks if a developer is currently authenticated
  32. #
  33. # @return [Boolean]
  34. def developer_authenticated?
  35. current_developer.present?
  36. end
  37. # Returns the currently authenticated developer
  38. #
  39. # @return [Developer, nil]
  40. def current_developer
  41. @current_developer ||= ::Developer.enabled.find_by(id: session[:developer_id])
  42. end
  43. # Returns the configured actor for admin suite actions/auditing/authorization.
  44. #
  45. # This must not assume `Current.user` because internal tools may use a separate
  46. # authentication mechanism (e.g. developer SSO).
  47. #
  48. # @return [Object, nil]
  49. def admin_suite_actor
  50. return nil unless defined?(AdminSuite)
  51. resolver = AdminSuite.config.current_actor
  52. resolver&.call(self)
  53. rescue StandardError
  54. nil
  55. end
  56. # Returns the current portal (ops, ai, or assistant)
  57. #
  58. # @return [Symbol, nil]
  59. def current_portal
  60. @current_portal ||= determine_portal
  61. end
  62. # Determines which portal we're in based on the resource
  63. #
  64. # @return [Symbol, nil]
  65. def determine_portal
  66. return nil unless resource_config
  67. resource_config.portal_name
  68. end
  69. # Returns the resource configuration class for the current controller
  70. #
  71. # @return [Class, nil]
  72. def resource_config
  73. @resource_config ||= find_resource_config
  74. end
  75. # Finds the resource configuration based on controller name
  76. #
  77. # @return [Class, nil]
  78. def find_resource_config
  79. resource_name = controller_name.singularize.camelize
  80. "Admin::Resources::#{resource_name}Resource".constantize
  81. rescue NameError
  82. nil
  83. end
  84. # Returns navigation items grouped by portal and section
  85. #
  86. # @return [Hash]
  87. def navigation_items
  88. @navigation_items ||= begin
  89. # Ensure all resources are loaded in development
  90. load_resources! if Rails.env.development?
  91. build_navigation
  92. end
  93. end
  94. # Loads all resource files (needed in development mode)
  95. #
  96. # @return [void]
  97. def load_resources!
  98. # Skip if already loaded
  99. return if Admin::Base::Resource.registered_resources.any?
  100. globs =
  101. if defined?(AdminSuite)
  102. AdminSuite.config.resource_globs
  103. else
  104. [ Rails.root.join("app/admin/resources/*.rb").to_s ]
  105. end
  106. Array(globs).flat_map { |g| Dir[g] }.uniq.each do |file|
  107. require file
  108. end
  109. rescue NameError
  110. # Admin::Base::Resource not defined yet, load it first
  111. require "admin/base/resource"
  112. retry
  113. end
  114. # Builds the navigation structure from registered resources
  115. #
  116. # @return [Hash]
  117. def build_navigation
  118. portals =
  119. if defined?(AdminSuite)
  120. AdminSuite.config.portals
  121. else
  122. {}
  123. end
  124. navigation = portals.each_with_object({}) do |(key, meta), h|
  125. h[key.to_sym] = {
  126. label: meta[:label] || key.to_s.humanize,
  127. icon: meta[:icon],
  128. color: meta[:color],
  129. order: meta[:order] || 100,
  130. sections: {}
  131. }
  132. end
  133. Admin::Base::Resource.registered_resources.each do |resource|
  134. next unless resource.portal_name && resource.section_name
  135. portal = resource.portal_name
  136. section = resource.section_name
  137. navigation[portal] ||= {
  138. label: portal.to_s.humanize,
  139. icon: nil,
  140. color: nil,
  141. order: 100,
  142. sections: {}
  143. }
  144. navigation[portal][:sections][section] ||= { label: section.to_s.humanize, items: [] }
  145. navigation[portal][:sections][section][:items] << {
  146. label: resource.human_name_plural,
  147. path: "/internal/developer/#{portal}/#{resource.resource_name_plural}",
  148. resource: resource
  149. }
  150. end
  151. navigation
  152. end
  153. end
  154. end
  155. end

app/controllers/internal/developer/dashboard_controller.rb

0.0% lines covered

100.0% branches covered

143 relevant lines. 0 lines covered and 143 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. # Dashboard controller for the developer portal
  5. #
  6. # Provides an overview of all registered resources, health metrics,
  7. # and quick access to different portals and sections.
  8. class DashboardController < BaseController
  9. before_action :load_resources!
  10. def index
  11. @resources_by_portal = build_resources_by_portal
  12. @stats = calculate_dashboard_stats
  13. @health = calculate_health_metrics
  14. @recent_activity = build_recent_activity
  15. end
  16. private
  17. # Loads all resource files (needed for Zeitwerk lazy loading)
  18. #
  19. # @return [void]
  20. def load_resources!
  21. # Skip if already loaded
  22. return if Admin::Base::Resource.registered_resources.any?
  23. # Load all resource definitions (require only loads once)
  24. Dir[Rails.root.join("app/admin/resources/*.rb").to_s].each do |file|
  25. require file
  26. end
  27. rescue NameError
  28. # Admin::Base::Resource not defined yet, load it first
  29. require "admin/base/resource"
  30. retry
  31. end
  32. # Groups resources by portal and section
  33. #
  34. # @return [Hash]
  35. def build_resources_by_portal
  36. resources = {}
  37. Admin::Base::Resource.registered_resources.each do |resource|
  38. portal = resource.portal_name || :other
  39. section = resource.section_name || :general
  40. resources[portal] ||= {}
  41. resources[portal][section] ||= []
  42. resources[portal][section] << resource
  43. end
  44. resources
  45. end
  46. # Calculates dashboard statistics
  47. #
  48. # @return [Hash]
  49. def calculate_dashboard_stats
  50. {
  51. total_resources: Admin::Base::Resource.registered_resources.count,
  52. portals: Admin::Base::Resource.registered_resources.map(&:portal_name).uniq.compact.count,
  53. ops_resources: Admin::Base::Resource.resources_for_portal(:ops).count,
  54. email_resources: Admin::Base::Resource.resources_for_portal(:email).count,
  55. ai_resources: Admin::Base::Resource.resources_for_portal(:ai).count,
  56. assistant_resources: Admin::Base::Resource.resources_for_portal(:assistant).count
  57. }
  58. end
  59. # Calculates health metrics for all systems
  60. #
  61. # @return [Hash]
  62. def calculate_health_metrics
  63. {
  64. scraping: scraping_health,
  65. llm: llm_health,
  66. assistant: assistant_health,
  67. app: app_health
  68. }
  69. end
  70. # Scraping system health
  71. def scraping_health
  72. recent_attempts = ScrapingAttempt.where("created_at > ?", 24.hours.ago)
  73. total = recent_attempts.count
  74. successful = recent_attempts.where(status: :completed).count
  75. failed = recent_attempts.where(status: :failed).count
  76. stuck = recent_attempts.where(status: :processing).where("updated_at < ?", 1.hour.ago).count
  77. success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
  78. status = if stuck > 5 || (total > 10 && success_rate < 50)
  79. :critical
  80. elsif stuck > 0 || (total > 10 && success_rate < 80)
  81. :degraded
  82. else
  83. :healthy
  84. end
  85. {
  86. status: status,
  87. metrics: {
  88. "24h attempts" => total,
  89. "success rate" => "#{success_rate}%",
  90. "failed" => failed,
  91. "stuck" => stuck
  92. }
  93. }
  94. end
  95. # LLM API health
  96. def llm_health
  97. recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
  98. total = recent_logs.count
  99. successful = recent_logs.where(status: :success).count
  100. failed = recent_logs.where(status: :failed).count
  101. # Calculate average latency
  102. avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
  103. total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
  104. total_cost = (total_cost_cents / 100.0).round(2)
  105. success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
  106. status = if total > 10 && success_rate < 80
  107. :critical
  108. elsif total > 10 && success_rate < 95
  109. :degraded
  110. else
  111. :healthy
  112. end
  113. {
  114. status: status,
  115. metrics: {
  116. "24h calls" => total,
  117. "success rate" => "#{success_rate}%",
  118. "avg latency" => "#{avg_latency}ms",
  119. "24h cost" => "$#{total_cost}"
  120. }
  121. }
  122. end
  123. # Assistant system health
  124. def assistant_health
  125. recent_threads = ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago)
  126. recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
  127. total_threads = recent_threads.count
  128. total_executions = recent_executions.count
  129. pending_approvals = ::Assistant::ToolExecution.where(status: :pending_approval).count
  130. failed_executions = recent_executions.where(status: :failed).count
  131. status = if pending_approvals > 20 || failed_executions > 10
  132. :degraded
  133. else
  134. :healthy
  135. end
  136. {
  137. status: status,
  138. metrics: {
  139. "24h threads" => total_threads,
  140. "24h tool runs" => total_executions,
  141. "pending approval" => pending_approvals,
  142. "failed" => failed_executions
  143. }
  144. }
  145. end
  146. # Overall app health
  147. def app_health
  148. {
  149. status: :healthy,
  150. metrics: {
  151. "users" => User.count,
  152. "24h signups" => User.where("created_at > ?", 24.hours.ago).count,
  153. "applications" => InterviewApplication.count,
  154. "job listings" => JobListing.enabled.count
  155. }
  156. }
  157. end
  158. # Build recent activity feed
  159. #
  160. # @return [Hash]
  161. def build_recent_activity
  162. {
  163. recent_users: User.order(created_at: :desc).limit(5),
  164. recent_applications: InterviewApplication.includes(:user, :company).order(created_at: :desc).limit(5),
  165. recent_threads: ::Assistant::ChatThread.includes(:user).order(created_at: :desc).limit(5),
  166. recent_scraping: ScrapingAttempt.order(created_at: :desc).limit(5)
  167. }
  168. end
  169. end
  170. end
  171. end

app/controllers/internal/developer/docs_controller.rb

0.0% lines covered

100.0% branches covered

127 relevant lines. 0 lines covered and 127 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. # Developer portal documentation viewer
  5. #
  6. # Provides a two-panel layout for browsing docs from Rails.root/docs
  7. class DocsController < BaseController
  8. DOCS_ROOT = Rails.root.join("docs").freeze
  9. # GET /internal/developer/docs
  10. # GET /internal/developer/docs?path=ASSISTANT_OVERVIEW.md
  11. def index
  12. @files = grouped_markdown_files
  13. @selected_path = params[:path].presence
  14. if @selected_path.present?
  15. load_doc_content(@selected_path)
  16. elsif @files.values.flatten.any?
  17. # Auto-select the first doc if none specified
  18. @selected_path = @files.values.flatten.first
  19. load_doc_content(@selected_path)
  20. end
  21. end
  22. # GET /internal/developer/docs/:path (for direct linking)
  23. def show
  24. relative_path = params[:path].to_s
  25. file_path = resolve_doc_path!(relative_path)
  26. @files = grouped_markdown_files
  27. @selected_path = relative_path
  28. @title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
  29. @raw_markdown = File.read(file_path)
  30. rendered = MarkdownRenderer.new(@raw_markdown).render
  31. @content_html = rendered[:html]
  32. @toc = rendered[:toc]
  33. @reading_time = rendered[:reading_time_minutes]
  34. render :index
  35. rescue ActiveRecord::RecordNotFound
  36. redirect_to internal_developer_docs_path, alert: "Doc not found."
  37. end
  38. private
  39. def load_doc_content(relative_path)
  40. file_path = resolve_doc_path!(relative_path)
  41. @title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
  42. @raw_markdown = File.read(file_path)
  43. rendered = MarkdownRenderer.new(@raw_markdown).render
  44. @content_html = rendered[:html]
  45. @toc = rendered[:toc]
  46. @reading_time = rendered[:reading_time_minutes]
  47. rescue ActiveRecord::RecordNotFound
  48. @title = nil
  49. @content_html = nil
  50. @toc = []
  51. @reading_time = nil
  52. end
  53. def grouped_markdown_files
  54. base = docs_root_realpath
  55. files = Dir.glob(base.join("**/*.md")).sort.map do |abs|
  56. abs_path = Pathname.new(abs)
  57. abs_path.relative_path_from(base).to_s
  58. end
  59. groups = files.group_by { |path| group_name_for_path(path) }
  60. # Sort groups with preferred order, then alphabetically
  61. preferred_order = [
  62. "Overview",
  63. "CICD",
  64. "Developer Portal",
  65. "Billing",
  66. "Google Integration",
  67. "Testing",
  68. "Assistant",
  69. "Admin UI",
  70. "Features",
  71. "Other"
  72. ]
  73. groups.sort_by { |k, _| [ preferred_order.index(k) || 999, k ] }.to_h
  74. end
  75. def group_name_for_path(relative_path)
  76. # Folder-based grouping: docs/<folder>/* -> "<Folder>" section
  77. folder = relative_path.to_s.split(File::SEPARATOR).first
  78. if folder.present? && folder != File.basename(relative_path.to_s)
  79. return humanize_folder_name(folder)
  80. end
  81. # Root files (docs/*.md): keep legacy prefix grouping for backward compatibility
  82. legacy_group_for_basename(File.basename(relative_path.to_s, ".md"))
  83. end
  84. def legacy_group_for_basename(name)
  85. if name == "README"
  86. "Overview"
  87. elsif name.start_with?("ASSISTANT_")
  88. "Assistant"
  89. elsif name.start_with?("ADMIN_")
  90. "Admin UI"
  91. elsif name.start_with?("GOOGLE_")
  92. "Google Integration"
  93. elsif name.start_with?("TEST")
  94. "Testing"
  95. elsif name.start_with?("DEVELOPER_PORTAL_")
  96. "Developer Portal"
  97. elsif name.include?("BILLING") || name.include?("SUBSCRIPTION")
  98. "Billing"
  99. else
  100. "Other"
  101. end
  102. end
  103. def humanize_folder_name(folder)
  104. normalized = folder.to_s.tr("_", " ").tr("-", " ").strip
  105. acronyms = {
  106. "cicd" => "CICD",
  107. "ci cd" => "CICD",
  108. "ai" => "AI",
  109. "ops" => "Ops",
  110. "oauth" => "OAuth",
  111. "ui" => "UI",
  112. "ux" => "UX",
  113. "api" => "API"
  114. }
  115. key = normalized.downcase
  116. return acronyms[key] if acronyms.key?(key)
  117. normalized.titleize
  118. end
  119. def resolve_doc_path!(relative_path)
  120. raise ActiveRecord::RecordNotFound if relative_path.blank?
  121. raise ActiveRecord::RecordNotFound if relative_path.include?("..")
  122. base = docs_root_realpath
  123. candidate = base.join(relative_path)
  124. raise ActiveRecord::RecordNotFound unless candidate.extname == ".md"
  125. real = candidate.realpath
  126. raise ActiveRecord::RecordNotFound unless real.to_s.start_with?(base.to_s + File::SEPARATOR)
  127. real.to_s
  128. rescue Errno::ENOENT, Errno::EACCES
  129. raise ActiveRecord::RecordNotFound
  130. end
  131. def docs_root_realpath
  132. DOCS_ROOT.realpath
  133. rescue Errno::ENOENT
  134. DOCS_ROOT
  135. end
  136. end
  137. end
  138. end

app/controllers/internal/developer/email/base_controller.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Email
  5. # Base controller for the Email Portal.
  6. class BaseController < Internal::Developer::BaseController
  7. helper_method :current_portal
  8. private
  9. def current_portal
  10. :email
  11. end
  12. def portal_resources
  13. Admin::Base::Resource.resources_for_portal(:email)
  14. end
  15. end
  16. end
  17. end
  18. end

app/controllers/internal/developer/email/dashboard_controller.rb

0.0% lines covered

100.0% branches covered

58 relevant lines. 0 lines covered and 58 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Email
  5. # Dashboard for the Email Portal (Signals pipeline observability).
  6. class DashboardController < BaseController
  7. before_action :load_resources!
  8. # GET /internal/developer/email
  9. def index
  10. @stats = calculate_portal_stats
  11. @health = calculate_health_metrics
  12. @recent = build_recent_activity
  13. end
  14. private
  15. def calculate_portal_stats
  16. {
  17. total_emails: SyncedEmail.count,
  18. unmatched: SyncedEmail.unmatched.count,
  19. matched: SyncedEmail.matched.count,
  20. needs_review: SyncedEmail.needs_review.count,
  21. pipeline_runs_24h: Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago).count,
  22. pipeline_events_24h: Signals::EmailPipelineEvent.where("created_at > ?", 24.hours.ago).count
  23. }
  24. end
  25. def calculate_health_metrics
  26. recent_runs = Signals::EmailPipelineRun.where("created_at > ?", 24.hours.ago)
  27. total = recent_runs.count
  28. successful = recent_runs.where(status: :success).count
  29. failed = recent_runs.where(status: :failed).count
  30. running = recent_runs.where(status: :started).count
  31. success_rate = total.positive? ? (successful.to_f / total * 100).round : 0
  32. avg_duration = recent_runs.where.not(duration_ms: nil).average(:duration_ms)&.round || 0
  33. status =
  34. if failed > 10 || (total > 20 && success_rate < 80)
  35. :critical
  36. elsif failed.positive? || (total > 20 && success_rate < 95) || running > 20
  37. :degraded
  38. else
  39. :healthy
  40. end
  41. {
  42. status: status,
  43. metrics: {
  44. "24h runs" => total,
  45. "success rate" => "#{success_rate}%",
  46. "failed" => failed,
  47. "running" => running,
  48. "avg duration" => "#{avg_duration}ms"
  49. }
  50. }
  51. end
  52. def build_recent_activity
  53. {
  54. emails: SyncedEmail.order(email_date: :desc).limit(8),
  55. runs: Signals::EmailPipelineRun.order(created_at: :desc).limit(8)
  56. }
  57. end
  58. end
  59. end
  60. end
  61. end

app/controllers/internal/developer/email/email_pipeline_events_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Email
  5. class EmailPipelineEventsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::EmailPipelineEventResource
  9. end
  10. def current_portal
  11. :email
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/email/email_pipeline_runs_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Email
  5. class EmailPipelineRunsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::EmailPipelineRunResource
  9. end
  10. def current_portal
  11. :email
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/email/synced_emails_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Email
  5. class SyncedEmailsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::SyncedEmailResource
  9. end
  10. def current_portal
  11. :email
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/base_controller.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # Base controller for the Ops Portal
  6. class BaseController < Internal::Developer::BaseController
  7. helper_method :current_portal
  8. private
  9. def current_portal
  10. :ops
  11. end
  12. def portal_resources
  13. Admin::Base::Resource.resources_for_portal(:ops)
  14. end
  15. end
  16. end
  17. end
  18. end

app/controllers/internal/developer/ops/blog_posts_controller.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class BlogPostsController < Internal::Developer::ResourcesController
  6. before_action :set_blog_post, only: %i[publish unpublish]
  7. # POST /internal/developer/ops/blog_posts/:id/publish
  8. def publish
  9. if @blog_post.update(status: :published, published_at: @blog_post.published_at || Time.current)
  10. redirect_to internal_developer_ops_blog_post_path(@blog_post), notice: "Blog post published successfully."
  11. else
  12. redirect_to internal_developer_ops_blog_post_path(@blog_post), alert: "Failed to publish blog post."
  13. end
  14. end
  15. # POST /internal/developer/ops/blog_posts/:id/unpublish
  16. def unpublish
  17. if @blog_post.update(status: :draft)
  18. redirect_to internal_developer_ops_blog_post_path(@blog_post), notice: "Blog post unpublished successfully."
  19. else
  20. redirect_to internal_developer_ops_blog_post_path(@blog_post), alert: "Failed to unpublish blog post."
  21. end
  22. end
  23. private
  24. def set_blog_post
  25. @blog_post = BlogPost.friendly.find(params[:id])
  26. end
  27. def resource_config
  28. Admin::Resources::BlogPostResource
  29. end
  30. def current_portal
  31. :ops
  32. end
  33. end
  34. end
  35. end
  36. end

app/controllers/internal/developer/ops/categories_controller.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # Categories controller for the Ops Portal
  6. class CategoriesController < Internal::Developer::ResourcesController
  7. def disable
  8. @resource.update(disabled: true) if @resource.respond_to?(:disabled=)
  9. redirect_to resource_url(@resource), notice: "Category disabled."
  10. end
  11. def enable
  12. @resource.update(disabled: false) if @resource.respond_to?(:disabled=)
  13. redirect_to resource_url(@resource), notice: "Category enabled."
  14. end
  15. def merge
  16. @merge_candidates = resource_class.where.not(id: @resource.id).order(:name).limit(100)
  17. end
  18. def merge_into
  19. target = resource_class.find(params[:target_id])
  20. result = Category.merge_categories(@resource, target)
  21. if result[:success]
  22. redirect_to resource_url(target), notice: "Categories merged successfully. #{result[:message]}"
  23. else
  24. redirect_to merge_internal_developer_ops_category_path(@resource), alert: result[:error]
  25. end
  26. rescue => e
  27. Rails.logger.error("Category merge failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
  28. redirect_to merge_internal_developer_ops_category_path(@resource), alert: "Merge failed: #{e.message}"
  29. end
  30. private
  31. def current_portal
  32. :ops
  33. end
  34. def resource_config
  35. Admin::Resources::CategoryResource
  36. end
  37. end
  38. end
  39. end
  40. end

app/controllers/internal/developer/ops/companies_controller.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # Companies controller for the Ops Portal
  6. class CompaniesController < Internal::Developer::ResourcesController
  7. def disable
  8. @resource.update(disabled: true) if @resource.respond_to?(:disabled=)
  9. redirect_to resource_url(@resource), notice: "Company disabled."
  10. end
  11. def enable
  12. @resource.update(disabled: false) if @resource.respond_to?(:disabled=)
  13. redirect_to resource_url(@resource), notice: "Company enabled."
  14. end
  15. def merge
  16. @merge_candidates = resource_class.where.not(id: @resource.id).order(:name).limit(100)
  17. end
  18. def merge_into
  19. target = resource_class.find(params[:target_id])
  20. result = Company.merge_companies(@resource, target)
  21. if result[:success]
  22. redirect_to resource_url(target), notice: "Companies merged successfully. #{result[:message]}"
  23. else
  24. redirect_to merge_internal_developer_ops_company_path(@resource), alert: result[:error]
  25. end
  26. rescue => e
  27. Rails.logger.error("Company merge failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
  28. redirect_to merge_internal_developer_ops_company_path(@resource), alert: "Merge failed: #{e.message}"
  29. end
  30. private
  31. def current_portal
  32. :ops
  33. end
  34. def resource_config
  35. Admin::Resources::CompanyResource
  36. end
  37. end
  38. end
  39. end
  40. end

app/controllers/internal/developer/ops/company_feedbacks_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # CompanyFeedbacks controller for the Ops Portal
  6. class CompanyFeedbacksController < Internal::Developer::ResourcesController
  7. private
  8. def current_portal
  9. :ops
  10. end
  11. def resource_config
  12. Admin::Resources::CompanyFeedbackResource
  13. end
  14. end
  15. end
  16. end
  17. end

app/controllers/internal/developer/ops/connected_accounts_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class ConnectedAccountsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::ConnectedAccountResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/dashboard_controller.rb

0.0% lines covered

100.0% branches covered

88 relevant lines. 0 lines covered and 88 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # Dashboard for the Ops Portal
  6. class DashboardController < BaseController
  7. before_action :load_resources!
  8. # GET /internal/developer/ops
  9. def index
  10. @resources_by_section = build_resources_by_section
  11. @stats = calculate_portal_stats
  12. @health = calculate_health_metrics
  13. @recent = build_recent_activity
  14. @charts = build_chart_data
  15. end
  16. private
  17. def build_resources_by_section
  18. resources = {}
  19. portal_resources.each do |resource|
  20. section = resource.section_name || :general
  21. resources[section] ||= []
  22. resources[section] << resource
  23. end
  24. resources
  25. end
  26. def calculate_portal_stats
  27. {
  28. total_resources: portal_resources.count,
  29. companies: Company.count,
  30. job_roles: JobRole.count,
  31. categories: Category.count,
  32. skill_tags: SkillTag.count,
  33. users: User.count,
  34. applications: InterviewApplication.count,
  35. job_listings: JobListing.count
  36. }
  37. end
  38. def calculate_health_metrics
  39. {
  40. scraping: scraping_health,
  41. email_sync: email_sync_health
  42. }
  43. end
  44. def scraping_health
  45. recent = ScrapingAttempt.where("created_at > ?", 24.hours.ago)
  46. total = recent.count
  47. completed = recent.where(status: :completed).count
  48. failed = recent.where(status: :failed).count
  49. stuck = recent.where(status: :processing).where("updated_at < ?", 30.minutes.ago).count
  50. rate = total > 0 ? (completed.to_f / total * 100).round : 0
  51. status = if stuck > 5 || (total > 10 && rate < 50)
  52. :critical
  53. elsif stuck > 0 || (total > 10 && rate < 80)
  54. :degraded
  55. else
  56. :healthy
  57. end
  58. { status: status, total: total, completed: completed, failed: failed, stuck: stuck, rate: rate }
  59. end
  60. def email_sync_health
  61. recent = SyncedEmail.where("created_at > ?", 24.hours.ago)
  62. total = recent.count
  63. pending = SyncedEmail.where(status: :needs_review).count
  64. processed = recent.where(status: :processed).count
  65. { total: total, pending: pending, processed: processed }
  66. end
  67. def build_recent_activity
  68. {
  69. users: User.order(created_at: :desc).limit(5),
  70. applications: InterviewApplication.includes(:user, :company).order(created_at: :desc).limit(5),
  71. job_listings: JobListing.includes(:company).order(created_at: :desc).limit(5),
  72. scraping: ScrapingAttempt.order(created_at: :desc).limit(5)
  73. }
  74. end
  75. def build_chart_data
  76. # Last 7 days of scraping attempts
  77. scraping_by_day = (0..6).map do |i|
  78. date = i.days.ago.to_date
  79. count = ScrapingAttempt.where(created_at: date.beginning_of_day..date.end_of_day).count
  80. { label: date.strftime("%a"), value: count }
  81. end.reverse
  82. # Last 7 days of signups
  83. signups_by_day = (0..6).map do |i|
  84. date = i.days.ago.to_date
  85. count = User.where(created_at: date.beginning_of_day..date.end_of_day).count
  86. { label: date.strftime("%a"), value: count }
  87. end.reverse
  88. { scraping: scraping_by_day, signups: signups_by_day }
  89. end
  90. end
  91. end
  92. end
  93. end

app/controllers/internal/developer/ops/email_senders_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class EmailSendersController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::EmailSenderResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/html_scraping_logs_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class HtmlScrapingLogsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::HtmlScrapingLogResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/interview_applications_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class InterviewApplicationsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::InterviewApplicationResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/interview_round_types_controller.rb

0.0% lines covered

100.0% branches covered

32 relevant lines. 0 lines covered and 32 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # InterviewRoundTypes controller for the Ops Portal
  6. #
  7. # Provides CRUD and toggle operations for managing interview round types.
  8. class InterviewRoundTypesController < Internal::Developer::ResourcesController
  9. # POST /internal/developer/ops/interview_round_types/:id/disable
  10. def disable
  11. @resource.disable!
  12. redirect_to resource_url(@resource), notice: "Round type disabled."
  13. end
  14. # POST /internal/developer/ops/interview_round_types/:id/enable
  15. def enable
  16. @resource.enable!
  17. redirect_to resource_url(@resource), notice: "Round type enabled."
  18. end
  19. # POST /internal/developer/ops/interview_round_types/:id/toggle
  20. def toggle
  21. if @resource.disabled?
  22. @resource.enable!
  23. redirect_to resource_url(@resource), notice: "Round type enabled."
  24. else
  25. @resource.disable!
  26. redirect_to resource_url(@resource), notice: "Round type disabled."
  27. end
  28. end
  29. private
  30. def current_portal
  31. :ops
  32. end
  33. def resource_config
  34. Admin::Resources::InterviewRoundTypeResource
  35. end
  36. end
  37. end
  38. end
  39. end

app/controllers/internal/developer/ops/interview_rounds_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # InterviewRounds controller for the Ops Portal
  6. class InterviewRoundsController < Internal::Developer::ResourcesController
  7. private
  8. def current_portal
  9. :ops
  10. end
  11. def resource_config
  12. Admin::Resources::InterviewRoundResource
  13. end
  14. end
  15. end
  16. end
  17. end

app/controllers/internal/developer/ops/job_listings_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class JobListingsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::JobListingResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/job_roles_controller.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # JobRoles controller for the Ops Portal
  6. class JobRolesController < Internal::Developer::ResourcesController
  7. def disable
  8. @resource.update(disabled: true) if @resource.respond_to?(:disabled=)
  9. redirect_to resource_url(@resource), notice: "Job role disabled."
  10. end
  11. def enable
  12. @resource.update(disabled: false) if @resource.respond_to?(:disabled=)
  13. redirect_to resource_url(@resource), notice: "Job role enabled."
  14. end
  15. def merge
  16. @merge_candidates = resource_class.where.not(id: @resource.id).order(:title).limit(100)
  17. end
  18. def merge_into
  19. target = resource_class.find(params[:target_id])
  20. result = JobRole.merge_job_roles(@resource, target)
  21. if result[:success]
  22. redirect_to resource_url(target), notice: "Job roles merged successfully. #{result[:message]}"
  23. else
  24. redirect_to merge_internal_developer_ops_job_role_path(@resource), alert: result[:error]
  25. end
  26. rescue => e
  27. Rails.logger.error("JobRole merge failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
  28. redirect_to merge_internal_developer_ops_job_role_path(@resource), alert: "Merge failed: #{e.message}"
  29. end
  30. private
  31. def current_portal
  32. :ops
  33. end
  34. def resource_config
  35. Admin::Resources::JobRoleResource
  36. end
  37. end
  38. end
  39. end
  40. end

app/controllers/internal/developer/ops/scraping_attempts_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class ScrapingAttemptsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::ScrapingAttemptResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/scraping_events_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class ScrapingEventsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::ScrapingEventResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/settings_controller.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # Settings controller for the Ops Portal
  6. class SettingsController < Internal::Developer::ResourcesController
  7. # POST /internal/developer/ops/settings/:id/toggle
  8. def toggle
  9. @resource.update!(value: !@resource.value)
  10. respond_to do |format|
  11. format.turbo_stream do
  12. render turbo_stream: turbo_stream.replace(
  13. dom_id(@resource, :toggle),
  14. partial: "internal/developer/shared/toggle_cell",
  15. locals: { record: @resource, field: :value }
  16. )
  17. end
  18. format.html { redirect_to resource_url(@resource), notice: "Setting toggled." }
  19. end
  20. end
  21. private
  22. def resource_config
  23. Admin::Resources::SettingResource
  24. end
  25. def current_portal
  26. :ops
  27. end
  28. end
  29. end
  30. end
  31. end

app/controllers/internal/developer/ops/skill_tags_controller.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. # SkillTags controller for the Ops Portal
  6. class SkillTagsController < Internal::Developer::ResourcesController
  7. def disable
  8. @resource.update(disabled: true) if @resource.respond_to?(:disabled=)
  9. redirect_to resource_url(@resource), notice: "Skill tag disabled."
  10. end
  11. def enable
  12. @resource.update(disabled: false) if @resource.respond_to?(:disabled=)
  13. redirect_to resource_url(@resource), notice: "Skill tag enabled."
  14. end
  15. def merge
  16. @merge_candidates = resource_class.where.not(id: @resource.id).order(:name).limit(100)
  17. end
  18. def merge_into
  19. target = resource_class.find(params[:target_id])
  20. result = SkillTag.merge_skills(@resource, target)
  21. if result[:success]
  22. redirect_to resource_url(target), notice: "Skill tags merged successfully. #{result[:message]}"
  23. else
  24. redirect_to merge_internal_developer_ops_skill_tag_path(@resource), alert: result[:error]
  25. end
  26. rescue => e
  27. Rails.logger.error("Merge failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
  28. redirect_to merge_internal_developer_ops_skill_tag_path(@resource), alert: "Merge failed: #{e.message}"
  29. end
  30. private
  31. def current_portal
  32. :ops
  33. end
  34. def resource_config
  35. Admin::Resources::SkillTagResource
  36. end
  37. end
  38. end
  39. end
  40. end

app/controllers/internal/developer/ops/support_tickets_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class SupportTicketsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::SupportTicketResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/synced_emails_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class SyncedEmailsController < Internal::Developer::ResourcesController
  6. private
  7. def resource_config
  8. Admin::Resources::SyncedEmailResource
  9. end
  10. def current_portal
  11. :ops
  12. end
  13. end
  14. end
  15. end
  16. end

app/controllers/internal/developer/ops/users_controller.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Ops
  5. class UsersController < Internal::Developer::ResourcesController
  6. # POST /internal/developer/ops/users/:id/resend_verification_email
  7. # Resends the email verification link to the user
  8. def resend_verification_email
  9. if resource.email_verified?
  10. redirect_to resource_url(resource), alert: "User email is already verified."
  11. else
  12. UserMailer.verify_email(resource).deliver_later
  13. redirect_to resource_url(resource), notice: "Verification email sent to #{resource.email_address}."
  14. end
  15. end
  16. # POST /internal/developer/ops/users/:id/grant_admin
  17. # Grants admin privileges to the user
  18. def grant_admin
  19. resource.update!(is_admin: true)
  20. redirect_to resource_url(resource), notice: "Granted admin privileges to #{resource.display_name}."
  21. end
  22. # POST /internal/developer/ops/users/:id/revoke_admin
  23. # Revokes admin privileges from the user
  24. def revoke_admin
  25. actor = admin_suite_actor
  26. if actor.is_a?(User) && resource == actor
  27. redirect_to resource_url(resource), alert: "You cannot revoke your own admin privileges."
  28. else
  29. resource.update!(is_admin: false)
  30. redirect_to resource_url(resource), notice: "Revoked admin privileges from #{resource.display_name}."
  31. end
  32. end
  33. # POST /internal/developer/ops/users/:id/grant_billing_admin_access
  34. def grant_billing_admin_access
  35. actor = admin_suite_actor
  36. Billing::AdminAccessService.new(user: resource, actor: (actor.is_a?(User) ? actor : nil)).grant!
  37. redirect_to resource_url(resource), notice: "Granted Admin/Developer billing access."
  38. end
  39. # POST /internal/developer/ops/users/:id/revoke_billing_admin_access
  40. def revoke_billing_admin_access
  41. actor = admin_suite_actor
  42. Billing::AdminAccessService.new(user: resource, actor: (actor.is_a?(User) ? actor : nil)).revoke!
  43. redirect_to resource_url(resource), notice: "Revoked Admin/Developer billing access."
  44. end
  45. private
  46. def resource_config
  47. Admin::Resources::UserResource
  48. end
  49. def current_portal
  50. :ops
  51. end
  52. end
  53. end
  54. end
  55. end

app/controllers/internal/developer/payments/dashboard_controller.rb

0.0% lines covered

100.0% branches covered

23 relevant lines. 0 lines covered and 23 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Payments
  5. # Dashboard for the Payments Portal.
  6. class DashboardController < Internal::Developer::BaseController
  7. before_action :load_resources!
  8. # GET /internal/developer/payments
  9. def index
  10. @stats = {
  11. plans: Billing::Plan.count,
  12. features: Billing::Feature.count,
  13. entitlements: Billing::PlanEntitlement.count,
  14. mappings: Billing::ProviderMapping.count,
  15. subscriptions: Billing::Subscription.count,
  16. webhook_events_pending: Billing::WebhookEvent.where(status: "pending").count
  17. }
  18. end
  19. private
  20. def current_portal
  21. :payments
  22. end
  23. end
  24. end
  25. end
  26. end

app/controllers/internal/developer/payments/features_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Payments
  5. # Features controller for the Payments Portal.
  6. class FeaturesController < Internal::Developer::ResourcesController
  7. private
  8. def current_portal
  9. :payments
  10. end
  11. def resource_config
  12. Admin::Resources::BillingFeatureResource
  13. end
  14. end
  15. end
  16. end
  17. end

app/controllers/internal/developer/payments/plan_entitlements_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Payments
  5. # Plan entitlements controller for the Payments Portal.
  6. class PlanEntitlementsController < Internal::Developer::ResourcesController
  7. private
  8. def current_portal
  9. :payments
  10. end
  11. def resource_config
  12. Admin::Resources::BillingPlanEntitlementResource
  13. end
  14. end
  15. end
  16. end
  17. end

app/controllers/internal/developer/payments/plans_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Payments
  5. # Plans controller for the Payments Portal.
  6. class PlansController < Internal::Developer::ResourcesController
  7. private
  8. def current_portal
  9. :payments
  10. end
  11. def resource_config
  12. Admin::Resources::BillingPlanResource
  13. end
  14. end
  15. end
  16. end
  17. end

app/controllers/internal/developer/payments/provider_mappings_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Payments
  5. # Provider mappings controller for the Payments Portal.
  6. class ProviderMappingsController < Internal::Developer::ResourcesController
  7. private
  8. def current_portal
  9. :payments
  10. end
  11. def resource_config
  12. Admin::Resources::BillingProviderMappingResource
  13. end
  14. end
  15. end
  16. end
  17. end

app/controllers/internal/developer/payments/subscriptions_controller.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Payments
  5. # Subscriptions controller for the Payments Portal (read-only).
  6. class SubscriptionsController < Internal::Developer::ResourcesController
  7. private
  8. def current_portal
  9. :payments
  10. end
  11. def resource_config
  12. Admin::Resources::BillingSubscriptionResource
  13. end
  14. end
  15. end
  16. end
  17. end

app/controllers/internal/developer/payments/webhook_events_controller.rb

0.0% lines covered

100.0% branches covered

20 relevant lines. 0 lines covered and 20 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. module Payments
  5. # Webhook events controller for the Payments Portal (read-only + replay).
  6. class WebhookEventsController < Internal::Developer::ResourcesController
  7. # POST /internal/developer/payments/webhook_events/:id/replay
  8. def replay
  9. @resource.update!(status: "pending", processed_at: nil, error_message: nil)
  10. Billing::ProcessWebhookEventJob.perform_later(@resource)
  11. redirect_to resource_url(@resource), notice: "Webhook event replay enqueued."
  12. end
  13. private
  14. def current_portal
  15. :payments
  16. end
  17. def resource_config
  18. Admin::Resources::BillingWebhookEventResource
  19. end
  20. end
  21. end
  22. end
  23. end

app/controllers/internal/developer/resources_controller.rb

0.0% lines covered

100.0% branches covered

218 relevant lines. 0 lines covered and 218 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. # Generic resources controller for the admin framework
  5. #
  6. # Provides CRUD operations for any resource defined with Admin::Base::Resource.
  7. # Can be inherited by specific resource controllers or used directly.
  8. class ResourcesController < BaseController
  9. include Pagy::Backend
  10. include Pagy::Frontend
  11. include Rails.application.routes.url_helpers
  12. before_action :set_resource, if: -> { params[:id].present? && action_name != "index" && action_name != "new" && action_name != "create" }
  13. helper_method :resource_class, :collection, :resource
  14. protected
  15. # Override view prefixes to also look in resources/ folder for shared views
  16. # This allows specific controllers to have their own views, falling back to shared ones
  17. def _prefixes
  18. @_prefixes ||= super + [ "internal/developer/resources" ]
  19. end
  20. public
  21. # GET /internal/developer/:resources
  22. def index
  23. @stats = calculate_stats if resource_config&.index_config&.stats_list&.any?
  24. @pagy, @collection = paginate_collection(filtered_collection)
  25. end
  26. # GET /internal/developer/:resources/:id
  27. def show
  28. end
  29. # GET /internal/developer/:resources/new
  30. def new
  31. @resource = resource_class.new
  32. end
  33. # GET /internal/developer/:resources/:id/edit
  34. def edit
  35. end
  36. # POST /internal/developer/:resources
  37. def create
  38. @resource = resource_class.new(resource_params)
  39. if @resource.save
  40. redirect_to resource_url(@resource), notice: "#{resource_config.human_name} was successfully created."
  41. else
  42. render :new, status: :unprocessable_entity
  43. end
  44. end
  45. # PATCH/PUT /internal/developer/:resources/:id
  46. def update
  47. if @resource.update(resource_params)
  48. redirect_to resource_url(@resource), notice: "#{resource_config.human_name} was successfully updated."
  49. else
  50. render :edit, status: :unprocessable_entity
  51. end
  52. end
  53. # DELETE /internal/developer/:resources/:id
  54. def destroy
  55. @resource.destroy!
  56. redirect_to collection_url, notice: "#{resource_config.human_name} was successfully deleted."
  57. end
  58. # POST /internal/developer/:resources/:id/execute_action/:action_name
  59. def execute_action
  60. action_name = params[:action_name].to_sym
  61. action_def = find_action(action_name)
  62. if action_def.nil?
  63. redirect_to resource_url(@resource), alert: "Action not found."
  64. return
  65. end
  66. executor = Admin::Base::ActionExecutor.new(resource_config, action_name, admin_suite_actor)
  67. result = executor.execute_member(@resource, params.to_unsafe_h)
  68. if result.success?
  69. redirect_to resource_url(@resource), notice: result.message
  70. else
  71. redirect_to resource_url(@resource), alert: result.message
  72. end
  73. end
  74. # POST /internal/developer/:portal/:resource_name/:id/toggle
  75. def toggle
  76. field = toggle_field_param
  77. unless field
  78. respond_to do |format|
  79. format.turbo_stream { head :unprocessable_entity }
  80. format.html { redirect_to resource_url(@resource), alert: "Toggle field is missing." }
  81. end
  82. return
  83. end
  84. unless toggleable_fields.include?(field)
  85. respond_to do |format|
  86. format.turbo_stream { head :unprocessable_entity }
  87. format.html { redirect_to resource_url(@resource), alert: "Toggle field is not allowed." }
  88. end
  89. return
  90. end
  91. current_value = !!@resource.public_send(field)
  92. @resource.update!(field => !current_value)
  93. respond_to do |format|
  94. format.turbo_stream do
  95. render turbo_stream: turbo_stream.replace(
  96. dom_id(@resource, :toggle),
  97. partial: "internal/developer/shared/toggle_cell",
  98. locals: { record: @resource, field: field }
  99. )
  100. end
  101. format.html { redirect_to resource_url(@resource), notice: "#{resource_config.human_name} updated." }
  102. end
  103. end
  104. # POST /internal/developer/:resources/bulk_action/:action_name
  105. def bulk_action
  106. action_name = params[:action_name].to_sym
  107. ids = params[:ids] || []
  108. if ids.empty?
  109. redirect_to collection_url, alert: "No items selected."
  110. return
  111. end
  112. model = resource_class
  113. if ids.all? { |id| uuid_param?(id) } && model.column_names.include?("uuid")
  114. records = model.where(uuid: ids)
  115. else
  116. records = model.where(id: ids)
  117. end
  118. executor = Admin::Base::ActionExecutor.new(resource_config, action_name, admin_suite_actor)
  119. result = executor.execute_bulk(records, params.to_unsafe_h)
  120. if result.success?
  121. redirect_to collection_url, notice: result.message
  122. else
  123. redirect_to collection_url, alert: result.message
  124. end
  125. end
  126. protected
  127. # Resolves the resource configuration.
  128. #
  129. # For normal resource controllers (e.g. `Internal::Developer::Ops::UsersController`),
  130. # `BaseController#resource_config` uses `controller_name`.
  131. #
  132. # For the generic action routes:
  133. # `/internal/developer/:portal/:resource_name/:id/execute_action/:action_name`,
  134. # we need to resolve based on `params[:resource_name]`.
  135. #
  136. # @return [Class, nil]
  137. def resource_config
  138. return super unless params[:resource_name].present?
  139. resource_name = params[:resource_name].to_s.singularize.camelize
  140. "Admin::Resources::#{resource_name}Resource".constantize
  141. rescue NameError
  142. nil
  143. end
  144. # Returns the model class for the resource
  145. #
  146. # @return [Class]
  147. def resource_class
  148. resource_config&.model_class || controller_name.classify.constantize
  149. end
  150. # Returns the current resource instance
  151. #
  152. # @return [ActiveRecord::Base]
  153. def resource
  154. @resource
  155. end
  156. # Returns the current collection
  157. #
  158. # @return [ActiveRecord::Relation]
  159. def collection
  160. @collection
  161. end
  162. private
  163. # Sets the resource from params
  164. #
  165. # @return [void]
  166. def set_resource
  167. model = resource_config.model_class
  168. @resource = find_resource(model, params[:id])
  169. end
  170. def find_resource(model, param)
  171. if uuid_param?(param) && model.column_names.include?("uuid")
  172. model.find_by!(uuid: param)
  173. else
  174. model.find(param)
  175. end
  176. end
  177. def uuid_param?(value)
  178. value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
  179. end
  180. # Returns the base collection with any default scopes
  181. #
  182. # @return [ActiveRecord::Relation]
  183. def base_collection
  184. resource_config.model_class.all
  185. end
  186. # Applies filters and search to the collection
  187. #
  188. # @return [ActiveRecord::Relation]
  189. def filtered_collection
  190. return base_collection unless resource_config&.index_config
  191. Admin::Base::FilterBuilder.new(resource_config, params).apply(base_collection)
  192. end
  193. # Paginates the collection
  194. #
  195. # @param scope [ActiveRecord::Relation] Collection to paginate
  196. # @return [Array<Pagy, ActiveRecord::Relation>]
  197. def paginate_collection(scope)
  198. per_page = resource_config&.index_config&.per_page || 25
  199. pagy(scope, items: per_page)
  200. end
  201. # Calculates stats for the index view
  202. #
  203. # @return [Array<Hash>]
  204. def calculate_stats
  205. resource_config.index_config.stats_list.map do |stat_def|
  206. # Stats procs don't take arguments - they query directly
  207. value = begin
  208. stat_def.calculator.call
  209. rescue => e
  210. Rails.logger.error("Error calculating stat #{stat_def.name}: #{e.message}")
  211. "N/A"
  212. end
  213. {
  214. name: stat_def.name.to_s.humanize,
  215. value: value,
  216. color: stat_def.color
  217. }
  218. end
  219. end
  220. # Finds an action definition by name
  221. #
  222. # @param name [Symbol] Action name
  223. # @return [ActionDefinition, nil]
  224. def find_action(name)
  225. resource_config&.actions_config&.member_actions&.find { |a| a.name == name }
  226. end
  227. # Returns permitted parameters based on form configuration
  228. #
  229. # @return [ActionController::Parameters]
  230. def resource_params
  231. permitted_fields = []
  232. array_fields = []
  233. resource_config&.form_config&.fields_list&.each do |field|
  234. next if field.is_a?(Admin::Base::Resource::SectionDefinition) ||
  235. field.is_a?(Admin::Base::Resource::SectionEnd) ||
  236. field.is_a?(Admin::Base::Resource::RowDefinition) ||
  237. field.is_a?(Admin::Base::Resource::RowEnd)
  238. # Tags and multi-select fields need to be permitted as arrays
  239. if field.type == :tags || field.type == :multi_select
  240. array_fields << { field.name => [] }
  241. # Also permit tag_list for :tags type
  242. array_fields << { tag_list: [] } if field.type == :tags && field.name != :tag_list
  243. else
  244. permitted_fields << field.name
  245. end
  246. end
  247. # Handle STI: the form is built from the concrete record class (e.g. Ai::AssistantSystemPrompt)
  248. # but the resource config may be the base class (e.g. Ai::LlmPrompt).
  249. param_keys = [
  250. (@resource&.class&.model_name&.param_key if defined?(@resource)),
  251. resource_class.model_name.param_key
  252. ].compact.uniq
  253. key = param_keys.find { |k| params.key?(k) }
  254. params.require(key).permit(permitted_fields + array_fields)
  255. end
  256. def toggle_field_param
  257. field = params[:field].presence
  258. field&.to_sym
  259. end
  260. def toggleable_fields
  261. return [] unless resource_config&.index_config&.columns_list
  262. resource_config.index_config.columns_list.filter_map do |column|
  263. next unless column.type == :toggle
  264. (column.toggle_field || column.name).to_sym
  265. end
  266. end
  267. # Returns the URL for a resource
  268. #
  269. # @param record [ActiveRecord::Base] Record
  270. # @return [String]
  271. def resource_url(record)
  272. url_for(controller: resource_controller_path, action: :show, id: record.to_param)
  273. end
  274. # Returns the URL for the collection
  275. #
  276. # @return [String]
  277. def collection_url
  278. url_for(controller: resource_controller_path, action: :index)
  279. end
  280. # Returns the correct controller path for redirects when using generic routes.
  281. #
  282. # @return [String]
  283. def resource_controller_path
  284. if params[:portal].present? && params[:resource_name].present?
  285. "/internal/developer/#{params[:portal]}/#{params[:resource_name]}"
  286. else
  287. controller_path
  288. end
  289. end
  290. end
  291. end
  292. end

app/controllers/internal/developer/sessions_controller.rb

0.0% lines covered

100.0% branches covered

73 relevant lines. 0 lines covered and 73 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Internal
  3. module Developer
  4. # Controller for developer portal authentication via TechWright SSO
  5. #
  6. # Handles login, OAuth callbacks, and logout for the admin portal.
  7. # Developers authenticate separately from regular users.
  8. class SessionsController < ApplicationController
  9. # Skip regular user authentication - developers use TechWright SSO
  10. allow_unauthenticated_access
  11. # Skip CSRF for OAuth callbacks (they come from external provider)
  12. skip_before_action :verify_authenticity_token, only: [ :create, :failure ]
  13. layout "developer_login"
  14. # GET /internal/developer/login
  15. #
  16. # Shows the developer login page with TechWright SSO button
  17. def new
  18. redirect_to internal_developer_root_path if developer_authenticated?
  19. end
  20. # GET /internal
  21. #
  22. # Redirects to developer portal if signed in, otherwise to login
  23. def redirect_root
  24. if developer_authenticated?
  25. redirect_to internal_developer_root_path
  26. else
  27. redirect_to internal_developer_login_path
  28. end
  29. end
  30. # GET /auth/failure (for TechWright)
  31. #
  32. # Handles TechWright OAuth authentication failures
  33. def failure
  34. error_message = params[:message] || "unknown error"
  35. Rails.logger.warn "TechWright OAuth failure: #{error_message}"
  36. redirect_to internal_developer_login_path,
  37. alert: "Authentication failed: #{error_message.humanize}. Please try again."
  38. end
  39. # GET/POST /auth/techwright/callback
  40. #
  41. # Handles TechWright OAuth callback, creates or updates developer record
  42. def create
  43. auth = request.env["omniauth.auth"]
  44. if auth.nil?
  45. redirect_to internal_developer_login_path, alert: "Authentication failed. Please try again."
  46. return
  47. end
  48. developer = ::Developer.find_or_create_from_omniauth(auth)
  49. unless developer.enabled?
  50. redirect_to internal_developer_login_path,
  51. alert: "Your developer access has been disabled."
  52. return
  53. end
  54. developer.record_login!(ip_address: request.remote_ip)
  55. session[:developer_id] = developer.id
  56. redirect_to internal_developer_root_path,
  57. notice: "Welcome, #{developer.name || developer.email}!"
  58. end
  59. # DELETE /internal/developer/logout
  60. #
  61. # Signs out the developer and optionally revokes the OAuth token
  62. def destroy
  63. revoke_token if current_developer&.access_token.present?
  64. session.delete(:developer_id)
  65. redirect_to internal_developer_login_path,
  66. notice: "Signed out successfully.", status: :see_other
  67. end
  68. private
  69. # Revokes the OAuth token at TechWright
  70. #
  71. # @return [void]
  72. def revoke_token
  73. return unless current_developer&.access_token.present?
  74. # Use token_site for server-side requests (supports devcontainer setups)
  75. site = Rails.application.credentials.dig(:techwright, :token_site) ||
  76. Rails.application.credentials.dig(:techwright, :site) ||
  77. "https://techwright.io"
  78. uri = URI("#{site}/oauth/revoke")
  79. http = Net::HTTP.new(uri.host, uri.port)
  80. http.use_ssl = uri.scheme == "https"
  81. request = Net::HTTP::Post.new(uri)
  82. request.set_form_data(
  83. token: current_developer.access_token,
  84. client_id: Rails.application.credentials.dig(:techwright, :client_id),
  85. client_secret: Rails.application.credentials.dig(:techwright, :client_secret)
  86. )
  87. http.request(request)
  88. rescue StandardError => e
  89. Rails.logger.error("TechWright token revocation failed: #{e.message}")
  90. end
  91. # Checks if a developer is currently authenticated
  92. #
  93. # @return [Boolean]
  94. def developer_authenticated?
  95. current_developer.present?
  96. end
  97. # Returns the currently authenticated developer
  98. #
  99. # @return [Developer, nil]
  100. def current_developer
  101. @current_developer ||= ::Developer.enabled.find_by(id: session[:developer_id])
  102. end
  103. end
  104. end
  105. end

app/controllers/interview_application_preps_controller.rb

0.0% lines covered

100.0% branches covered

29 relevant lines. 0 lines covered and 29 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class InterviewApplicationPrepsController < ApplicationController
  3. before_action :set_application
  4. # POST /interview_applications/:interview_application_id/prep/refresh
  5. def refresh
  6. ent = Billing::Entitlements.for(Current.user)
  7. unless ent.allowed?(:interview_prepare_access)
  8. redirect_to interview_application_path(@application, tab: "prepare"),
  9. alert: "Upgrade to unlock full Prepare",
  10. status: :see_other
  11. return
  12. end
  13. remaining = ent.remaining(:interview_prepare_refreshes)
  14. if remaining.is_a?(Integer) && remaining <= 0
  15. redirect_to interview_application_path(@application, tab: "prepare"),
  16. alert: "You’ve reached your monthly refresh limit",
  17. status: :see_other
  18. return
  19. end
  20. GenerateInterviewPrepPackJob.perform_later(@application, user: Current.user)
  21. redirect_to interview_application_path(@application, tab: "prepare"),
  22. notice: "Generating prep…",
  23. status: :see_other
  24. end
  25. private
  26. def set_application
  27. @application = Current.user.interview_applications.not_deleted.find(params[:interview_application_id])
  28. rescue ActiveRecord::RecordNotFound
  29. redirect_to interview_applications_path, alert: "Application not found"
  30. end
  31. end

app/controllers/interview_applications_controller.rb

0.0% lines covered

100.0% branches covered

364 relevant lines. 0 lines covered and 364 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing interview application tracking
  3. class InterviewApplicationsController < ApplicationController
  4. before_action :set_application, only: [ :show, :edit, :update, :update_pipeline_stage, :update_job_description, :archive, :reject, :accept, :reactivate ]
  5. before_action :set_application_for_soft_delete, only: [ :destroy ]
  6. before_action :set_deleted_application, only: [ :restore ]
  7. before_action :set_view_preference, only: [ :index, :kanban ]
  8. # GET /interview_applications
  9. def index
  10. scope = Current.user.interview_applications
  11. @status_counts = scope.not_deleted.group(:status).count
  12. @deleted_count = scope.deleted.count
  13. base_applications = scope
  14. .includes(:company, :job_role, :job_listing, :skill_tags, :interview_rounds)
  15. if params[:status] == "deleted"
  16. base_applications = base_applications.deleted
  17. else
  18. base_applications = base_applications.not_deleted
  19. base_applications = base_applications.where(status: params[:status]) if params[:status].present?
  20. end
  21. # Apply search
  22. if params[:q].present?
  23. search_term = "%#{params[:q].strip}%"
  24. base_applications = base_applications
  25. .left_outer_joins(:company, :job_role)
  26. .where(
  27. "companies.name ILIKE :q OR job_roles.title ILIKE :q",
  28. q: search_term
  29. )
  30. end
  31. # Apply filters
  32. base_applications = base_applications.where(pipeline_stage: params[:pipeline_stage]) if params[:pipeline_stage].present?
  33. if params[:date_from].present?
  34. begin
  35. base_applications = base_applications.where("applied_at >= ?", Date.parse(params[:date_from]))
  36. rescue ArgumentError
  37. # Invalid date format, ignore filter
  38. end
  39. end
  40. if params[:date_to].present?
  41. begin
  42. base_applications = base_applications.where("applied_at <= ?", Date.parse(params[:date_to]))
  43. rescue ArgumentError
  44. # Invalid date format, ignore filter
  45. end
  46. end
  47. # Apply sorting
  48. case params[:sort]
  49. when "company"
  50. base_applications = base_applications.joins(:company).order("companies.name ASC")
  51. when "company_desc"
  52. base_applications = base_applications.joins(:company).order("companies.name DESC")
  53. when "role"
  54. base_applications = base_applications.joins(:job_role).order("job_roles.title ASC")
  55. when "role_desc"
  56. base_applications = base_applications.joins(:job_role).order("job_roles.title DESC")
  57. when "date"
  58. base_applications = base_applications.order("applied_at ASC, created_at ASC")
  59. when "date_desc"
  60. base_applications = base_applications.order("applied_at DESC, created_at DESC")
  61. else
  62. base_applications = base_applications.recent
  63. end
  64. # Paginate for table view, load all for kanban
  65. if @current_view == "kanban"
  66. @applications = base_applications.to_a
  67. # Group by pipeline stage for kanban columns
  68. @applications_by_pipeline_stage = InterviewApplication::PIPELINE_STAGES.index_with do |stage|
  69. @applications.select { |app| app.pipeline_stage == stage.to_s }
  70. end
  71. else
  72. @pagy, @applications = pagy(base_applications, limit: 20)
  73. end
  74. end
  75. # GET /interview_applications/kanban
  76. def kanban
  77. @applications = Current.user.interview_applications
  78. .includes(:company, :job_role, :interview_rounds)
  79. .not_deleted
  80. .active
  81. .recent
  82. @applications_by_pipeline_stage = InterviewApplication::PIPELINE_STAGES.index_with do |stage|
  83. @applications.select { |app| app.pipeline_stage == stage.to_s }
  84. end
  85. end
  86. # GET /interview_applications/:id
  87. def show
  88. @interview_rounds = @application.interview_rounds.ordered
  89. @next_upcoming_round = @application.interview_rounds.upcoming.order(scheduled_at: :asc).first
  90. @company_feedback = @application.company_feedback
  91. @synced_emails = @application.synced_emails.recent
  92. @application_timeline = ApplicationTimelineService.new(@application)
  93. @extracted_company_careers_url = @synced_emails
  94. .find { |email| email.signal_company_careers_url.present? }
  95. &.signal_company_careers_url
  96. @extracted_action_links = build_extracted_action_links(@synced_emails)
  97. @join_interview_link = build_join_interview_link(@next_upcoming_round, @extracted_action_links)
  98. @reschedule_link = build_reschedule_link(@extracted_action_links)
  99. @prep_artifacts_by_kind = @application.interview_prep_artifacts.recent_first.index_by(&:kind)
  100. focus = @prep_artifacts_by_kind["focus_areas"]&.content
  101. focus_items = Array(focus&.dig("focus_areas")).filter_map { |i| i.is_a?(Hash) ? i["title"] : nil }
  102. @prep_focus_areas_preview = focus_items.first(3)
  103. end
  104. # GET /interview_applications/new
  105. def new
  106. @application = Current.user.interview_applications.build
  107. @companies = Company.alphabetical.limit(100)
  108. @job_roles = JobRole.alphabetical.limit(100)
  109. end
  110. # GET /interview_applications/:id/edit
  111. def edit
  112. @companies = Company.alphabetical.limit(100)
  113. @job_roles = JobRole.alphabetical.limit(100)
  114. end
  115. # POST /interview_applications
  116. def create
  117. @application = Current.user.interview_applications.build(application_params)
  118. # Set defaults (AASM will set initial states, but we ensure applied_at is set)
  119. @application.applied_at ||= Date.today
  120. if @application.save
  121. # Create job listing from URL if provided
  122. if params[:interview_application][:job_listing_url].present?
  123. CreateJobListingFromUrlService.new(@application, params[:interview_application][:job_listing_url]).call
  124. end
  125. respond_to do |format|
  126. format.html { redirect_to interview_applications_path, notice: "Application added successfully!" }
  127. format.turbo_stream { redirect_to interview_applications_path, notice: "Application added successfully!", status: :see_other }
  128. end
  129. else
  130. @companies = Company.alphabetical.limit(100)
  131. @job_roles = JobRole.alphabetical.limit(100)
  132. respond_to do |format|
  133. format.html { render :new, status: :unprocessable_entity }
  134. format.turbo_stream { render :new, status: :unprocessable_entity }
  135. end
  136. end
  137. end
  138. # PATCH/PUT /interview_applications/:id
  139. def update
  140. if @application.update(application_params)
  141. job_listing_url = params.dig(:interview_application, :job_listing_url)
  142. if job_listing_url.present?
  143. CreateJobListingFromUrlService.new(@application, job_listing_url).call
  144. end
  145. redirect_to interview_applications_path, notice: "Application updated successfully!"
  146. else
  147. @companies = Company.alphabetical.limit(100)
  148. @job_roles = JobRole.alphabetical.limit(100)
  149. render :edit, status: :unprocessable_entity
  150. end
  151. end
  152. # PATCH /interview_applications/:id/update_job_description
  153. def update_job_description
  154. if @application.update(job_description_params)
  155. redirect_to interview_application_path(@application, tab: "prepare"),
  156. notice: "Job description saved",
  157. status: :see_other
  158. else
  159. redirect_to interview_application_path(@application, tab: "prepare"),
  160. alert: "Could not save job description",
  161. status: :see_other
  162. end
  163. end
  164. # DELETE /interview_applications/:id
  165. def destroy
  166. if @application.soft_delete!
  167. notice = "Application deleted. You can restore it within 3 months."
  168. # If the delete happened from the show page, redirecting back would hit a now-unreachable URL.
  169. if request.referer&.match?(%r{/applications/[^/?]+$})
  170. redirect_to interview_applications_path, notice:, status: :see_other
  171. else
  172. redirect_back fallback_location: interview_applications_path, notice:, status: :see_other
  173. end
  174. else
  175. redirect_back fallback_location: interview_application_path(@application), alert: "Could not delete application", status: :see_other
  176. end
  177. end
  178. # PATCH /interview_applications/:id/update_pipeline_stage
  179. def update_pipeline_stage
  180. target_stage = params[:pipeline_stage]&.to_sym
  181. # Map target stage to appropriate AASM event
  182. event_method = case target_stage
  183. when :screening
  184. :move_to_screening
  185. when :interviewing
  186. :move_to_interviewing
  187. when :offer
  188. :move_to_offer
  189. when :closed
  190. :move_to_closed
  191. when :applied
  192. :move_to_applied
  193. else
  194. nil
  195. end
  196. if event_method && @application.aasm(:pipeline_stage).may_fire_event?(event_method)
  197. if @application.send("#{event_method}!")
  198. notice = "Moved to #{target_stage.to_s.titleize}"
  199. respond_to do |format|
  200. format.html { redirect_back fallback_location: interview_applications_path, notice: notice, status: :see_other }
  201. format.turbo_stream { redirect_back fallback_location: interview_applications_path, notice: notice, status: :see_other }
  202. format.json { render json: { success: true, pipeline_stage: @application.pipeline_stage } }
  203. end
  204. else
  205. respond_to do |format|
  206. format.html { redirect_back fallback_location: interview_applications_path, alert: "Failed to update stage", status: :see_other }
  207. format.turbo_stream { redirect_back fallback_location: interview_applications_path, alert: "Failed to update stage", status: :see_other }
  208. format.json { render json: { success: false, errors: @application.errors }, status: :unprocessable_entity }
  209. end
  210. end
  211. else
  212. respond_to do |format|
  213. format.html { redirect_back fallback_location: interview_applications_path, alert: "Invalid stage transition", status: :see_other }
  214. format.turbo_stream { redirect_back fallback_location: interview_applications_path, alert: "Invalid stage transition", status: :see_other }
  215. format.json { render json: { success: false, errors: [ "Invalid stage transition" ] }, status: :unprocessable_entity }
  216. end
  217. end
  218. end
  219. # PATCH /interview_applications/:id/archive
  220. def archive
  221. if @application.may_archive? && @application.archive!
  222. redirect_back fallback_location: interview_application_path(@application), notice: "Application archived", status: :see_other
  223. else
  224. redirect_back fallback_location: interview_application_path(@application), alert: "Cannot archive this application", status: :see_other
  225. end
  226. end
  227. # PATCH /interview_applications/:id/reject
  228. def reject
  229. if @application.may_reject? && @application.reject!
  230. redirect_back fallback_location: interview_application_path(@application), notice: "Application marked as rejected", status: :see_other
  231. else
  232. redirect_back fallback_location: interview_application_path(@application), alert: "Cannot reject this application", status: :see_other
  233. end
  234. end
  235. # PATCH /interview_applications/:id/accept
  236. def accept
  237. if @application.may_accept? && @application.accept!
  238. redirect_back fallback_location: interview_application_path(@application), notice: "Congratulations! Application marked as accepted", status: :see_other
  239. else
  240. redirect_back fallback_location: interview_application_path(@application), alert: "Cannot accept this application", status: :see_other
  241. end
  242. end
  243. # PATCH /interview_applications/:id/reactivate
  244. def reactivate
  245. if @application.may_reactivate? && @application.reactivate!
  246. redirect_back fallback_location: interview_application_path(@application), notice: "Application reactivated", status: :see_other
  247. else
  248. redirect_back fallback_location: interview_application_path(@application), alert: "Cannot reactivate this application", status: :see_other
  249. end
  250. end
  251. # PATCH /interview_applications/:id/restore
  252. def restore
  253. if @application.restore!
  254. redirect_back fallback_location: interview_applications_path, notice: "Application restored", status: :see_other
  255. else
  256. redirect_back fallback_location: interview_applications_path(status: "deleted"), alert: "Could not restore application", status: :see_other
  257. end
  258. end
  259. # POST /interview_applications/quick_apply
  260. def quick_apply
  261. url = params[:url]&.strip
  262. if url.blank?
  263. respond_to do |format|
  264. format.json { render json: { success: false, error: "URL is required" }, status: :unprocessable_entity }
  265. format.html { redirect_to interview_applications_path, alert: "URL is required" }
  266. end
  267. return
  268. end
  269. service = QuickApplyFromUrlService.new(url, Current.user)
  270. result = service.call
  271. if result[:success]
  272. application = result[:application]
  273. respond_to do |format|
  274. format.json do
  275. render json: {
  276. success: true,
  277. application: {
  278. id: application.id,
  279. slug: application.slug,
  280. company_name: application.company.name,
  281. job_role_title: application.job_role.title,
  282. url: interview_application_path(application)
  283. },
  284. message: "Application created successfully!"
  285. }
  286. end
  287. format.html do
  288. redirect_to interview_application_path(application), notice: "Application created successfully!"
  289. end
  290. format.turbo_stream do
  291. flash.now[:notice] = "Application created successfully!"
  292. render turbo_stream: [
  293. turbo_stream.replace("flash", partial: "shared/flash"),
  294. turbo_stream.redirect_to(interview_application_path(application))
  295. ]
  296. end
  297. end
  298. else
  299. error_message = result[:error] || "Failed to create application"
  300. respond_to do |format|
  301. format.json do
  302. render json: {
  303. success: false,
  304. error: error_message
  305. }, status: :unprocessable_entity
  306. end
  307. format.html do
  308. redirect_to interview_applications_path, alert: error_message
  309. end
  310. format.turbo_stream do
  311. flash.now[:alert] = error_message
  312. render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash")
  313. end
  314. end
  315. end
  316. rescue => e
  317. Rails.logger.error("Quick apply failed: #{e.message}")
  318. Rails.logger.error(e.backtrace.join("\n"))
  319. respond_to do |format|
  320. format.json do
  321. render json: {
  322. success: false,
  323. error: "An error occurred: #{e.message}"
  324. }, status: :internal_server_error
  325. end
  326. format.html do
  327. redirect_to interview_applications_path, alert: "An error occurred. Please try again."
  328. end
  329. format.turbo_stream do
  330. flash.now[:alert] = "An error occurred. Please try again."
  331. render turbo_stream: turbo_stream.replace("flash", partial: "shared/flash")
  332. end
  333. end
  334. end
  335. private
  336. # Builds a de-duplicated list of action links extracted from emails
  337. #
  338. # @param emails [Enumerable<SyncedEmail>]
  339. # @return [Array<Hash>]
  340. def build_extracted_action_links(emails)
  341. require "set"
  342. seen = Set.new
  343. Array(emails).flat_map(&:action_links).each_with_object([]) do |link, links|
  344. url = link["url"].to_s.strip
  345. next if url.blank? || seen.include?(url)
  346. seen.add(url)
  347. links << link
  348. end
  349. end
  350. # Picks a join interview link from rounds or extracted links
  351. #
  352. # @param next_round [InterviewRound, nil]
  353. # @param links [Array<Hash>]
  354. # @return [String, nil]
  355. def build_join_interview_link(next_round, links)
  356. return next_round.video_link if next_round&.video_link.present?
  357. join_link = links.find do |link|
  358. label = link["action_label"].to_s.downcase
  359. label.include?("join") || label.include?("zoom") || label.include?("meet")
  360. end
  361. join_link&.dig("url")
  362. end
  363. # Picks a reschedule/scheduling link from extracted links
  364. #
  365. # @param links [Array<Hash>]
  366. # @return [String, nil]
  367. def build_reschedule_link(links)
  368. link = links.find do |item|
  369. label = item["action_label"].to_s.downcase
  370. label.include?("reschedule") || label.include?("schedule") || label.include?("book")
  371. end
  372. link&.dig("url")
  373. end
  374. def set_application
  375. @application = Current.user.interview_applications.not_deleted.find(params[:id])
  376. rescue ActiveRecord::RecordNotFound
  377. redirect_to interview_applications_path, alert: "Application not found"
  378. end
  379. def set_application_for_soft_delete
  380. @application = Current.user.interview_applications.not_deleted.find(params[:id])
  381. rescue ActiveRecord::RecordNotFound
  382. redirect_to interview_applications_path, alert: "Application not found"
  383. end
  384. def set_deleted_application
  385. @application = Current.user.interview_applications.deleted.find(params[:id])
  386. rescue ActiveRecord::RecordNotFound
  387. redirect_to interview_applications_path(status: "deleted"), alert: "Deleted application not found"
  388. end
  389. def set_view_preference
  390. # Get view from params or user preference
  391. view = params[:view] || Current.user.preference.preferred_view
  392. # Normalize view names: "list" -> "table"
  393. @current_view = (view == "list") ? "table" : view
  394. @current_view = "table" if params[:status] == "deleted"
  395. end
  396. def application_params
  397. params.expect(interview_application: [
  398. :company_id,
  399. :job_role_id,
  400. :applied_at,
  401. :notes,
  402. :job_description_text
  403. ])
  404. end
  405. def job_description_params
  406. params.expect(interview_application: [ :job_description_text ])
  407. end
  408. end

app/controllers/interview_feedbacks_controller.rb

0.0% lines covered

100.0% branches covered

70 relevant lines. 0 lines covered and 70 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing self-reflection feedback for interview rounds
  3. class InterviewFeedbacksController < ApplicationController
  4. before_action :set_interview_round
  5. before_action :set_interview_feedback, only: [ :edit, :update, :destroy ]
  6. # GET /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback/new
  7. def new
  8. @feedback = @round.interview_feedback || @round.build_interview_feedback
  9. end
  10. # POST /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback
  11. def create
  12. @feedback = @round.build_interview_feedback(feedback_params)
  13. if @feedback.save
  14. trial_result = maybe_unlock_insight_trial_after_feedback
  15. notice = "Self-reflection added successfully!"
  16. if trial_result[:unlocked]
  17. notice = "#{notice} You’ve unlocked Pro insights for 72 hours."
  18. end
  19. redirect_to interview_application_path(@round.interview_application), notice: notice
  20. else
  21. render :new, status: :unprocessable_entity
  22. end
  23. end
  24. # GET /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback/edit
  25. def edit
  26. end
  27. # PATCH/PUT /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback
  28. def update
  29. if @feedback.update(feedback_params)
  30. redirect_to interview_application_path(@round.interview_application), notice: "Self-reflection updated successfully!"
  31. else
  32. render :edit, status: :unprocessable_entity
  33. end
  34. end
  35. # DELETE /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/interview_feedback
  36. def destroy
  37. @feedback.destroy
  38. redirect_to interview_application_path(@round.interview_application), notice: "Self-reflection deleted successfully!"
  39. end
  40. private
  41. def set_interview_round
  42. @round = Current.user.interview_applications
  43. .find(params[:interview_application_id])
  44. .interview_rounds
  45. .find(params[:interview_round_id])
  46. rescue ActiveRecord::RecordNotFound
  47. redirect_to interview_applications_path, alert: "Interview round not found"
  48. end
  49. def set_interview_feedback
  50. @feedback = @round.interview_feedback
  51. redirect_to interview_application_path(@round.interview_application), alert: "Self-reflection not found" unless @feedback
  52. end
  53. def feedback_params
  54. params.require(:interview_feedback).permit(
  55. :went_well,
  56. :to_improve,
  57. :self_reflection,
  58. :interviewer_notes,
  59. :tag_list
  60. )
  61. end
  62. # Unlocks the insight-triggered trial when the user has uploaded a CV and is adding their first feedback entry.
  63. #
  64. # @return [Hash] Trial unlock result
  65. def maybe_unlock_insight_trial_after_feedback
  66. user = Current.user
  67. return { unlocked: false } if user.nil?
  68. return { unlocked: false } unless user.user_resumes.exists?
  69. feedback_count = InterviewFeedback
  70. .joins(interview_round: { interview_application: :user })
  71. .where(users: { id: user.id })
  72. .count
  73. return { unlocked: false } unless feedback_count == 1
  74. Billing::TrialUnlockService.new(
  75. user: user,
  76. trigger: :first_feedback_after_cv,
  77. metadata: { feedback_count: feedback_count }
  78. ).run
  79. end
  80. end

app/controllers/interview_round_preps_controller.rb

0.0% lines covered

100.0% branches covered

60 relevant lines. 0 lines covered and 60 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing interview round preparation content
  3. class InterviewRoundPrepsController < ApplicationController
  4. before_action :set_application
  5. before_action :set_round
  6. # GET /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/prep
  7. # Shows the prep content for a specific round
  8. def show
  9. @prep = @round.prep
  10. @entitlements = Billing::Entitlements.for(Current.user)
  11. end
  12. # POST /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/prep/generate
  13. # Generates or regenerates prep content for a round
  14. def generate
  15. ent = Billing::Entitlements.for(Current.user)
  16. # Check access
  17. unless ent.allowed?(:round_prep_access)
  18. redirect_to interview_application_path(@application, anchor: "rounds"),
  19. alert: "Round prep requires a Pro or Sprint subscription."
  20. return
  21. end
  22. # Check quota
  23. remaining = ent.remaining(:round_prep_generations)
  24. if remaining.is_a?(Integer) && remaining <= 0
  25. redirect_to interview_application_path(@application, anchor: "rounds"),
  26. alert: "You've used all your round prep generations for this month."
  27. return
  28. end
  29. @artifact = InterviewRoundPrepArtifact.find_or_initialize_for(
  30. interview_round: @round,
  31. kind: :comprehensive
  32. )
  33. # Enqueue the job for background generation
  34. GenerateRoundPrepJob.perform_later(@round)
  35. respond_to do |format|
  36. format.html do
  37. redirect_to interview_application_path(@application, anchor: "rounds"),
  38. notice: "Generating interview prep for #{@round.stage_display_name}..."
  39. end
  40. format.turbo_stream do
  41. flash.now[:notice] = "Generating interview prep..."
  42. end
  43. end
  44. end
  45. # GET /interview_applications/:interview_application_id/interview_rounds/:interview_round_id/prep/status
  46. # Returns the current generation status (for polling)
  47. def status
  48. @artifact = @round.prep_artifacts.find_by(kind: :comprehensive)
  49. respond_to do |format|
  50. format.json do
  51. render json: {
  52. status: @artifact&.status || "not_started",
  53. generated_at: @artifact&.generated_at,
  54. has_content: @artifact&.has_content?
  55. }
  56. end
  57. format.turbo_stream
  58. end
  59. end
  60. private
  61. def set_application
  62. @application = Current.user.interview_applications.find(params[:interview_application_id])
  63. rescue ActiveRecord::RecordNotFound
  64. redirect_to interview_applications_path, alert: "Application not found"
  65. end
  66. def set_round
  67. @round = @application.interview_rounds.find(params[:interview_round_id])
  68. rescue ActiveRecord::RecordNotFound
  69. redirect_to interview_application_path(@application), alert: "Interview round not found"
  70. end
  71. end

app/controllers/interview_rounds_controller.rb

0.0% lines covered

100.0% branches covered

83 relevant lines. 0 lines covered and 83 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing interview rounds within an application
  3. class InterviewRoundsController < ApplicationController
  4. before_action :set_application
  5. before_action :set_round, only: [ :show, :edit, :update, :destroy ]
  6. # GET /interview_applications/:interview_application_id/interview_rounds
  7. def index
  8. @rounds = @application.interview_rounds.ordered
  9. end
  10. # GET /interview_applications/:interview_application_id/interview_rounds/:id
  11. def show
  12. end
  13. # GET /interview_applications/:interview_application_id/interview_rounds/new
  14. def new
  15. @round = @application.interview_rounds.build(position: @application.interview_rounds.count + 1)
  16. end
  17. # GET /interview_applications/:interview_application_id/interview_rounds/:id/edit
  18. def edit
  19. end
  20. # POST /interview_applications/:interview_application_id/interview_rounds
  21. def create
  22. @round = @application.interview_rounds.build(round_params)
  23. if @round.save
  24. respond_to do |format|
  25. format.html { redirect_to interview_application_path(@application), notice: "Interview round added successfully!" }
  26. format.turbo_stream { flash.now[:notice] = "Interview round added successfully!" }
  27. end
  28. else
  29. render :new, status: :unprocessable_entity
  30. end
  31. end
  32. # PATCH/PUT /interview_applications/:interview_application_id/interview_rounds/:id
  33. def update
  34. previously_completed = @round.completed_at.present?
  35. if @round.update(round_params)
  36. maybe_unlock_insight_trial_after_second_completion(previously_completed: previously_completed)
  37. respond_to do |format|
  38. format.html { redirect_to interview_application_path(@application), notice: "Interview round updated successfully!" }
  39. format.turbo_stream { flash.now[:notice] = "Interview round updated successfully!" }
  40. end
  41. else
  42. render :edit, status: :unprocessable_entity
  43. end
  44. end
  45. # DELETE /interview_applications/:interview_application_id/interview_rounds/:id
  46. def destroy
  47. @round.destroy
  48. respond_to do |format|
  49. format.html { redirect_to interview_application_path(@application), notice: "Interview round deleted successfully!", status: :see_other }
  50. format.turbo_stream { flash.now[:notice] = "Interview round deleted successfully!" }
  51. end
  52. end
  53. private
  54. def set_application
  55. @application = Current.user.interview_applications.find(params[:interview_application_id])
  56. rescue ActiveRecord::RecordNotFound
  57. redirect_to interview_applications_path, alert: "Application not found"
  58. end
  59. def set_round
  60. @round = @application.interview_rounds.find(params[:id])
  61. rescue ActiveRecord::RecordNotFound
  62. redirect_to interview_application_path(@application), alert: "Interview round not found"
  63. end
  64. def round_params
  65. params.expect(interview_round: [
  66. :stage,
  67. :stage_name,
  68. :scheduled_at,
  69. :completed_at,
  70. :duration_minutes,
  71. :interviewer_name,
  72. :interviewer_role,
  73. :notes,
  74. :result,
  75. :position,
  76. :interview_round_type_id
  77. ])
  78. end
  79. # Unlocks the insight-triggered trial when the user completes their 2nd interview round.
  80. #
  81. # @param previously_completed [Boolean]
  82. # @return [void]
  83. def maybe_unlock_insight_trial_after_second_completion(previously_completed:)
  84. return if previously_completed
  85. return unless @round.completed_at.present?
  86. user = Current.user
  87. return if user.nil?
  88. completed_count = user.interview_rounds.completed.count
  89. return unless completed_count == 2
  90. Billing::TrialUnlockService.new(
  91. user: user,
  92. trigger: :second_interview_completed,
  93. metadata: { completed_count: completed_count, interview_round_id: @round.id }
  94. ).run
  95. end
  96. end

app/controllers/job_listings_controller.rb

0.0% lines covered

100.0% branches covered

116 relevant lines. 0 lines covered and 116 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing job listings
  3. class JobListingsController < ApplicationController
  4. before_action :set_job_listing, only: [:show, :edit, :update, :destroy]
  5. # GET /job_listings
  6. def index
  7. @job_listings = JobListing.includes(:company, :job_role).recent
  8. if params[:company_id].present?
  9. @job_listings = @job_listings.where(company_id: params[:company_id])
  10. end
  11. if params[:job_role_id].present?
  12. @job_listings = @job_listings.where(job_role_id: params[:job_role_id])
  13. end
  14. if params[:remote_type].present?
  15. @job_listings = @job_listings.where(remote_type: params[:remote_type])
  16. end
  17. if params[:status].present?
  18. @job_listings = @job_listings.where(status: params[:status])
  19. else
  20. @job_listings = @job_listings.active
  21. end
  22. respond_to do |format|
  23. format.html
  24. format.json { render json: @job_listings }
  25. end
  26. end
  27. # GET /job_listings/:id
  28. def show
  29. @applications = @job_listing.interview_applications.includes(:user).recent
  30. end
  31. # GET /job_listings/new
  32. def new
  33. @job_listing = JobListing.new
  34. @companies = Company.alphabetical.limit(100)
  35. @job_roles = JobRole.alphabetical.limit(100)
  36. end
  37. # GET /job_listings/:id/edit
  38. def edit
  39. @companies = Company.alphabetical.limit(100)
  40. @job_roles = JobRole.alphabetical.limit(100)
  41. end
  42. # POST /job_listings
  43. def create
  44. @job_listing = JobListing.new(job_listing_params)
  45. process_custom_sections(@job_listing)
  46. if @job_listing.save
  47. respond_to do |format|
  48. format.html { redirect_to @job_listing, notice: "Job listing created successfully!" }
  49. format.turbo_stream { flash.now[:notice] = "Job listing created successfully!" }
  50. end
  51. else
  52. @companies = Company.alphabetical.limit(100)
  53. @job_roles = JobRole.alphabetical.limit(100)
  54. render :new, status: :unprocessable_entity
  55. end
  56. end
  57. # PATCH/PUT /job_listings/:id
  58. def update
  59. @job_listing.assign_attributes(job_listing_params)
  60. process_custom_sections(@job_listing)
  61. if @job_listing.save
  62. respond_to do |format|
  63. format.html { redirect_to @job_listing, notice: "Job listing updated successfully!" }
  64. format.turbo_stream { flash.now[:notice] = "Job listing updated successfully!" }
  65. end
  66. else
  67. @companies = Company.alphabetical.limit(100)
  68. @job_roles = JobRole.alphabetical.limit(100)
  69. render :edit, status: :unprocessable_entity
  70. end
  71. end
  72. # DELETE /job_listings/:id
  73. def destroy
  74. @job_listing.destroy
  75. respond_to do |format|
  76. format.html { redirect_to job_listings_path, notice: "Job listing deleted successfully!", status: :see_other }
  77. format.turbo_stream { flash.now[:notice] = "Job listing deleted successfully!" }
  78. end
  79. end
  80. private
  81. def set_job_listing
  82. @job_listing = JobListing.find(params[:id])
  83. rescue ActiveRecord::RecordNotFound
  84. redirect_to job_listings_path, alert: "Job listing not found"
  85. end
  86. def job_listing_params
  87. params.expect(job_listing: [
  88. :company_id,
  89. :job_role_id,
  90. :title,
  91. :url,
  92. :source_id,
  93. :job_board_id,
  94. :description,
  95. :requirements,
  96. :responsibilities,
  97. :salary_min,
  98. :salary_max,
  99. :salary_currency,
  100. :equity_info,
  101. :benefits,
  102. :perks,
  103. :location,
  104. :remote_type,
  105. :status,
  106. custom_sections_keys: [],
  107. custom_sections_values: [],
  108. custom_sections: {},
  109. scraped_data: {}
  110. ])
  111. end
  112. def process_custom_sections(job_listing)
  113. return unless params[:job_listing]
  114. keys = params[:job_listing][:custom_sections_keys]
  115. values = params[:job_listing][:custom_sections_values]
  116. if keys.present? && values.present?
  117. custom_sections = {}
  118. keys.each_with_index do |key, index|
  119. next if key.blank?
  120. custom_sections[key] = values[index] if values[index].present?
  121. end
  122. job_listing.custom_sections = custom_sections
  123. end
  124. end
  125. end

app/controllers/job_roles_controller.rb

0.0% lines covered

100.0% branches covered

61 relevant lines. 0 lines covered and 61 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing job roles
  3. class JobRolesController < ApplicationController
  4. # GET /job_roles
  5. def index
  6. @job_roles = JobRole.enabled.alphabetical
  7. if params[:q].present?
  8. @job_roles = @job_roles.where("title ILIKE ?", "%#{params[:q]}%")
  9. end
  10. if params[:category_id].present?
  11. @job_roles = @job_roles.by_category(params[:category_id])
  12. end
  13. @job_roles = @job_roles.limit(50)
  14. respond_to do |format|
  15. format.html
  16. format.json { render json: @job_roles }
  17. end
  18. end
  19. # GET /job_roles/autocomplete
  20. def autocomplete
  21. query = params[:q].to_s.strip
  22. @job_roles = if query.present?
  23. JobRole.enabled.where("title ILIKE ?", "%#{query}%")
  24. .alphabetical
  25. .limit(10)
  26. else
  27. JobRole.enabled.alphabetical.limit(10)
  28. end
  29. render json: @job_roles.map { |jr| { id: jr.id, title: jr.title, category: jr.category_name } }
  30. end
  31. # POST /job_roles
  32. def create
  33. # Handle both form params and JSON params (for auto-create)
  34. if request.format.json?
  35. # Auto-create from autocomplete - only title is required
  36. title = (params[:title] || params.dig(:job_role, :title))&.strip
  37. return render json: { errors: [ "Title is required" ] }, status: :unprocessable_entity if title.blank?
  38. # Find by case-insensitive title
  39. @job_role = JobRole.where("LOWER(title) = ?", title.downcase).first
  40. if @job_role.nil?
  41. # Create new job role
  42. @job_role = JobRole.new(title: title)
  43. if @job_role.save
  44. render json: { id: @job_role.id, title: @job_role.title, name: @job_role.title }, status: :created
  45. else
  46. render json: { errors: @job_role.errors.full_messages }, status: :unprocessable_entity
  47. end
  48. else
  49. # If it exists but was disabled, re-enable it
  50. @job_role.update!(disabled_at: nil) if @job_role.disabled?
  51. # Job role already exists, return it
  52. render json: { id: @job_role.id, title: @job_role.title, name: @job_role.title }, status: :ok
  53. end
  54. else
  55. # Regular form submission
  56. @job_role = JobRole.new(job_role_params)
  57. if @job_role.save
  58. respond_to do |format|
  59. format.html { redirect_to job_roles_path, notice: "Job role created successfully!" }
  60. format.turbo_stream { flash.now[:notice] = "Job role created successfully!" }
  61. end
  62. else
  63. respond_to do |format|
  64. format.html { render :new, status: :unprocessable_entity }
  65. end
  66. end
  67. end
  68. end
  69. private
  70. def job_role_params
  71. params.expect(job_role: [ :title, :category, :description ])
  72. end
  73. end

app/controllers/oauth_callbacks_controller.rb

0.0% lines covered

100.0% branches covered

60 relevant lines. 0 lines covered and 60 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for handling OAuth callbacks from external providers
  3. class OauthCallbacksController < ApplicationController
  4. # Allow unauthenticated access for sign-in/sign-up flow
  5. allow_unauthenticated_access only: [ :create, :failure ]
  6. # Skip CSRF for OAuth callbacks (they come from external providers)
  7. skip_before_action :verify_authenticity_token, only: [ :create ]
  8. # GET/POST /auth/:provider/callback
  9. # Handle successful OAuth authentication
  10. def create
  11. auth = request.env["omniauth.auth"]
  12. if auth.nil?
  13. handle_missing_auth
  14. return
  15. end
  16. begin
  17. if authenticated?
  18. # User is already signed in - connect OAuth account
  19. handle_account_connection(auth)
  20. else
  21. # User is not signed in - sign in or sign up with OAuth
  22. handle_authentication(auth)
  23. end
  24. rescue ActiveRecord::RecordInvalid => e
  25. Rails.logger.error "OAuth error: #{e.message}"
  26. handle_error(e.record.errors.full_messages.join(", "))
  27. rescue StandardError => e
  28. Rails.logger.error "OAuth error: #{e.class} - #{e.message}"
  29. handle_error("An error occurred. Please try again.")
  30. end
  31. end
  32. # GET /auth/failure
  33. # Handle OAuth authentication failures
  34. def failure
  35. error_message = params[:message] || "unknown error"
  36. error_strategy = params[:strategy] || "unknown"
  37. Rails.logger.warn "OAuth failure for #{error_strategy}: #{error_message}"
  38. redirect_path = authenticated? ? settings_path(tab: "integrations") : new_session_path
  39. redirect_to redirect_path,
  40. alert: "Authentication failed: #{error_message.humanize}. Please try again."
  41. end
  42. private
  43. # Handles the case when auth data is missing
  44. # @return [void]
  45. def handle_missing_auth
  46. redirect_path = authenticated? ? settings_path(tab: "integrations") : new_session_path
  47. redirect_to redirect_path, alert: "Authentication failed. Please try again."
  48. end
  49. # Handles connecting an OAuth account to an existing logged-in user
  50. # @param auth [OmniAuth::AuthHash] The OAuth authentication data
  51. # @return [void]
  52. def handle_account_connection(auth)
  53. @connected_account = ConnectedAccount.from_oauth(Current.user, auth)
  54. redirect_to settings_path(tab: "integrations"),
  55. notice: "Successfully connected your #{provider_name} account!"
  56. end
  57. # Handles sign-in or sign-up via OAuth
  58. # @param auth [OmniAuth::AuthHash] The OAuth authentication data
  59. # @return [void]
  60. def handle_authentication(auth)
  61. user = OauthAuthenticationService.new(auth).run
  62. start_new_session_for(user)
  63. redirect_to after_authentication_url,
  64. notice: "Welcome! Successfully signed in with #{provider_name}."
  65. end
  66. # Handles errors during OAuth flow
  67. # @param message [String] The error message
  68. # @return [void]
  69. def handle_error(message)
  70. redirect_path = authenticated? ? settings_path(tab: "integrations") : new_session_path
  71. redirect_to redirect_path, alert: message
  72. end
  73. # Returns a human-readable name for the provider
  74. # @return [String]
  75. def provider_name
  76. case params[:provider]
  77. when "google_oauth2"
  78. "Google"
  79. else
  80. params[:provider].to_s.titleize
  81. end
  82. end
  83. end

app/controllers/opportunities_controller.rb

0.0% lines covered

100.0% branches covered

204 relevant lines. 0 lines covered and 204 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing job opportunities from recruiter outreach
  3. # Presents opportunities in a stacked-cards UI with Apply/Ignore actions
  4. class OpportunitiesController < ApplicationController
  5. before_action :set_opportunity, only: [ :show, :apply, :ignore, :restore, :update_url ]
  6. # GET /opportunities
  7. #
  8. # Main opportunities view with stacked cards layout
  9. def index
  10. load_opportunity_stack(selected_id: params[:opportunity_id].presence&.to_i)
  11. respond_to do |format|
  12. format.html
  13. format.turbo_stream do
  14. render turbo_stream: [
  15. turbo_stream.update("opportunities_stack", partial: "opportunities/stack"),
  16. turbo_stream.update("opportunities_count", html: opportunities_count_badge)
  17. ]
  18. end
  19. end
  20. end
  21. # GET /opportunities/:id
  22. #
  23. # Show full opportunity details
  24. def show
  25. respond_to do |format|
  26. format.html
  27. format.turbo_stream do
  28. render turbo_stream: turbo_stream.update(
  29. "opportunity_detail",
  30. partial: "opportunities/card",
  31. locals: { opportunity: @opportunity, show_actions: true }
  32. )
  33. end
  34. end
  35. end
  36. # POST /opportunities/:id/apply
  37. #
  38. # Creates an InterviewApplication from the opportunity
  39. def apply
  40. service = Opportunities::CreateApplicationService.new(@opportunity, Current.user)
  41. result = service.call
  42. respond_to do |format|
  43. if result[:success]
  44. format.html do
  45. redirect_to result[:application],
  46. notice: "Application created for #{result[:company].name}!"
  47. end
  48. format.turbo_stream do
  49. load_opportunity_stack
  50. render turbo_stream: [
  51. turbo_stream.update("opportunities_stack", partial: "opportunities/stack"),
  52. turbo_stream.update("opportunities_count", html: opportunities_count_badge)
  53. ]
  54. end
  55. format.json { render json: { success: true, application_id: result[:application].id } }
  56. else
  57. format.html do
  58. redirect_to opportunities_path,
  59. alert: result[:error] || "Could not create application."
  60. end
  61. format.turbo_stream do
  62. render turbo_stream: turbo_stream.update(
  63. "flash",
  64. partial: "shared/flash",
  65. locals: { flash: { alert: result[:error] } }
  66. )
  67. end
  68. format.json { render json: { success: false, error: result[:error] }, status: :unprocessable_entity }
  69. end
  70. end
  71. end
  72. # POST /opportunities/:id/ignore
  73. #
  74. # Marks the opportunity as ignored and shows the next one
  75. def ignore
  76. if @opportunity.archive_as_ignored!
  77. respond_to do |format|
  78. format.html { redirect_to opportunities_path, notice: "Opportunity ignored." }
  79. format.turbo_stream do
  80. load_opportunity_stack
  81. render turbo_stream: [
  82. turbo_stream.update("opportunities_stack", partial: "opportunities/stack"),
  83. turbo_stream.update("opportunities_count", html: opportunities_count_badge)
  84. ]
  85. end
  86. format.json { render json: { success: true } }
  87. end
  88. else
  89. respond_to do |format|
  90. format.html { redirect_to opportunities_path, alert: "Could not ignore opportunity." }
  91. format.turbo_stream do
  92. render turbo_stream: turbo_stream.update(
  93. "flash",
  94. partial: "shared/flash",
  95. locals: { flash: { alert: "Could not ignore opportunity." } }
  96. )
  97. end
  98. format.json { render json: { success: false }, status: :unprocessable_entity }
  99. end
  100. end
  101. end
  102. # POST /opportunities/:id/restore
  103. #
  104. # Restores an archived opportunity back to the stack
  105. def restore
  106. if @opportunity.reconsider!
  107. respond_to do |format|
  108. format.html { redirect_to opportunities_path, notice: "Opportunity restored." }
  109. format.turbo_stream do
  110. load_opportunity_stack(selected_id: @opportunity.id)
  111. render turbo_stream: [
  112. turbo_stream.update("opportunities_stack", partial: "opportunities/stack"),
  113. turbo_stream.update("opportunities_count", html: opportunities_count_badge)
  114. ]
  115. end
  116. format.json { render json: { success: true } }
  117. end
  118. else
  119. respond_to do |format|
  120. format.html { redirect_to opportunities_path, alert: "Could not restore opportunity." }
  121. format.turbo_stream do
  122. render turbo_stream: turbo_stream.update(
  123. "flash",
  124. partial: "shared/flash",
  125. locals: { flash: { alert: "Could not restore opportunity." } }
  126. )
  127. end
  128. format.json { render json: { success: false }, status: :unprocessable_entity }
  129. end
  130. end
  131. end
  132. # PATCH /opportunities/:id/update_url
  133. #
  134. # Updates the job URL for manual entry
  135. def update_url
  136. if @opportunity.update(job_url: params[:job_url])
  137. # Optionally trigger extraction if URL changed
  138. if @opportunity.job_url.present? && @opportunity.saved_change_to_job_url?
  139. ProcessOpportunityEmailJob.perform_later(@opportunity.id)
  140. end
  141. respond_to do |format|
  142. format.html { redirect_to opportunity_path(@opportunity), notice: "URL updated." }
  143. format.turbo_stream do
  144. render turbo_stream: turbo_stream.update(
  145. "opportunity_card_#{@opportunity.id}",
  146. partial: "opportunities/card",
  147. locals: { opportunity: @opportunity, show_actions: true }
  148. )
  149. end
  150. format.json { render json: { success: true, job_url: @opportunity.job_url } }
  151. end
  152. else
  153. respond_to do |format|
  154. format.html { redirect_to opportunity_path(@opportunity), alert: "Could not update URL." }
  155. format.turbo_stream do
  156. render turbo_stream: turbo_stream.update(
  157. "flash",
  158. partial: "shared/flash",
  159. locals: { flash: { alert: "Could not update URL." } }
  160. )
  161. end
  162. format.json { render json: { success: false }, status: :unprocessable_entity }
  163. end
  164. end
  165. end
  166. private
  167. # Sets the opportunity for member actions
  168. #
  169. # @return [Opportunity]
  170. def set_opportunity
  171. @opportunity = current_user_opportunities.find(params[:id])
  172. rescue ActiveRecord::RecordNotFound
  173. respond_to do |format|
  174. format.html { redirect_to opportunities_path, alert: "Opportunity not found." }
  175. format.turbo_stream do
  176. render turbo_stream: turbo_stream.update(
  177. "flash",
  178. partial: "shared/flash",
  179. locals: { flash: { alert: "Opportunity not found." } }
  180. )
  181. end
  182. format.json { render json: { error: "Not found" }, status: :not_found }
  183. end
  184. end
  185. # Returns the current user's opportunities
  186. #
  187. # @return [ActiveRecord::Relation]
  188. def current_user_opportunities
  189. Current.user.opportunities
  190. end
  191. # Returns HTML for the opportunities count badge
  192. #
  193. # @return [String]
  194. def opportunities_count_badge
  195. count = actionable_unsaved_opportunities.count
  196. return "" if count == 0
  197. helpers.content_tag(:span, count,
  198. class: "ml-auto inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400"
  199. )
  200. end
  201. def actionable_unsaved_opportunities
  202. current_user_opportunities
  203. .actionable
  204. .left_outer_joins(:saved_job)
  205. .where("saved_jobs.id IS NULL OR saved_jobs.status = 'archived'")
  206. end
  207. def load_opportunity_stack(selected_id: nil)
  208. @opportunities = actionable_unsaved_opportunities
  209. .includes(:synced_email)
  210. .recent
  211. @current_opportunity = if selected_id
  212. @opportunities.detect { |o| o.id == selected_id } || @opportunities.first
  213. else
  214. @opportunities.first
  215. end
  216. if @current_opportunity
  217. ids = @opportunities.map(&:id)
  218. idx = ids.index(@current_opportunity.id) || 0
  219. @current_position = idx + 1
  220. @total_count = ids.length
  221. @prev_opportunity_id = idx.positive? ? ids[idx - 1] : nil
  222. @next_opportunity_id = (idx + 1) < ids.length ? ids[idx + 1] : nil
  223. @remaining_count = ids.length - @current_position
  224. else
  225. @current_position = 0
  226. @total_count = 0
  227. @prev_opportunity_id = nil
  228. @next_opportunity_id = nil
  229. @remaining_count = nil
  230. end
  231. end
  232. # Strong parameters for opportunity updates
  233. #
  234. # @return [ActionController::Parameters]
  235. def opportunity_params
  236. params.expect(opportunity: [ :job_url ])
  237. end
  238. end

app/controllers/passwords_controller.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PasswordsController < ApplicationController
  2. allow_unauthenticated_access
  3. before_action :set_user_by_token, only: %i[ edit update ]
  4. rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
  5. layout "authentication"
  6. def new
  7. end
  8. def create
  9. if user = User.find_by(email_address: params[:email_address])
  10. PasswordsMailer.reset(user).deliver_later
  11. end
  12. redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
  13. end
  14. def edit
  15. end
  16. def update
  17. if @user.update(params.permit(:password, :password_confirmation))
  18. @user.sessions.destroy_all
  19. redirect_to new_session_path, notice: "Password has been reset."
  20. else
  21. redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
  22. end
  23. end
  24. private
  25. def set_user_by_token
  26. @user = User.find_by_password_reset_token!(params[:token])
  27. rescue ActiveSupport::MessageVerifier::InvalidSignature
  28. redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
  29. end
  30. end

app/controllers/profiles_controller.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for user profile and insights
  3. class ProfilesController < ApplicationController
  4. # GET /profile
  5. def show
  6. @user = Current.user
  7. @insights = ProfileInsightsService.new(@user).generate_insights
  8. @companies = Company.alphabetical.limit(100)
  9. @job_roles = JobRole.alphabetical.limit(100)
  10. end
  11. # GET /profile/edit
  12. def edit
  13. @user = Current.user
  14. @companies = Company.alphabetical.limit(100)
  15. @job_roles = JobRole.alphabetical.limit(100)
  16. end
  17. # PATCH/PUT /profile
  18. def update
  19. @user = Current.user
  20. if @user.update(profile_params)
  21. redirect_to profile_path, notice: "Profile updated successfully!"
  22. else
  23. @companies = Company.alphabetical.limit(100)
  24. @job_roles = JobRole.alphabetical.limit(100)
  25. render :edit, status: :unprocessable_entity
  26. end
  27. end
  28. private
  29. def profile_params
  30. params.expect(user: [
  31. :name,
  32. :bio,
  33. :current_job_role_id,
  34. :current_company_id,
  35. :years_of_experience,
  36. :linkedin_url,
  37. :github_url,
  38. :gitlab_url,
  39. :twitter_url,
  40. :portfolio_url,
  41. target_job_role_ids: [],
  42. target_company_ids: []
  43. ])
  44. end
  45. end

app/controllers/public/base_controller.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Base controller for all public-facing pages
  4. #
  5. # Provides unauthenticated access for marketing pages like homepage,
  6. # contact, pricing, etc. All public controllers should inherit from this class.
  7. #
  8. # @example
  9. # class Public::HomeController < Public::BaseController
  10. # def index
  11. # # Public homepage action
  12. # end
  13. # end
  14. class BaseController < ApplicationController
  15. allow_unauthenticated_access
  16. layout "public"
  17. before_action :set_default_meta_tags
  18. private
  19. # Sets baseline SEO meta tags for public pages.
  20. #
  21. # Individual controllers/actions can override via `set_meta_tags`.
  22. # @return [void]
  23. def set_default_meta_tags
  24. set_meta_tags(
  25. site: "Gleania",
  26. reverse: true,
  27. separator: "—",
  28. description: "Gleania helps you track interviews, gather feedback, and grow your skills with AI-powered reflection.",
  29. canonical: request.original_url,
  30. og: {
  31. site_name: "Gleania",
  32. type: "website",
  33. url: request.original_url
  34. },
  35. twitter: {
  36. card: "summary_large_image"
  37. }
  38. )
  39. end
  40. # Redirects authenticated users to dashboard
  41. #
  42. # Can be used in before_action to redirect logged-in users
  43. # away from public pages like login/register.
  44. # @return [void]
  45. def redirect_authenticated_users
  46. redirect_to interview_applications_path if authenticated?
  47. end
  48. end
  49. end

app/controllers/public/blog_controller.rb

0.0% lines covered

100.0% branches covered

79 relevant lines. 0 lines covered and 79 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Controller for the public blog.
  4. class BlogController < BaseController
  5. # GET /blog
  6. def index
  7. @posts = BlogPost.published_publicly.recent_first
  8. @tag_cloud = ActsAsTaggableOn::Tag
  9. .joins(:taggings)
  10. .where(taggings: { taggable_type: "BlogPost", context: "tags" })
  11. .group("tags.id")
  12. .select("tags.*, COUNT(taggings.id) AS usage_count")
  13. .order("usage_count DESC, tags.name ASC")
  14. .limit(30)
  15. set_meta_tags(
  16. title: "Blog",
  17. description: "Product thinking, interview strategy, and job-search workflows from Gleania.",
  18. canonical: blog_index_url,
  19. og: {
  20. type: "website",
  21. url: blog_index_url,
  22. title: "The Gleania Blog",
  23. description: "Product thinking, interview strategy, and job-search workflows from Gleania.",
  24. site_name: "Gleania"
  25. },
  26. twitter: {
  27. card: "summary",
  28. site: "@GleaniaApp",
  29. title: "The Gleania Blog",
  30. description: "Product thinking, interview strategy, and job-search workflows from Gleania."
  31. }
  32. )
  33. end
  34. # GET /blog/:slug
  35. def show
  36. @post = BlogPost.friendly.find(params[:slug])
  37. raise ActiveRecord::RecordNotFound unless @post.publicly_visible?
  38. rendered = MarkdownRenderer.new(@post.body).render
  39. @content_html = rendered[:html]
  40. @toc = rendered[:toc]
  41. @reading_time_minutes = rendered[:reading_time_minutes]
  42. og_image =
  43. if @post.cover_image.attached?
  44. if Rails.env.production?
  45. @post.cover_image.url
  46. else
  47. url_for(@post.cover_image_variant(size: :og))
  48. end
  49. end
  50. description = @post.excerpt.presence || "Read #{@post.title} on the Gleania blog."
  51. set_meta_tags(
  52. title: @post.title,
  53. description: description,
  54. canonical: blog_url(@post.slug),
  55. # Open Graph (used by LinkedIn, Facebook, etc.)
  56. og: {
  57. type: "article",
  58. url: blog_url(@post.slug),
  59. title: @post.title,
  60. description: description,
  61. image: og_image,
  62. site_name: "Gleania"
  63. },
  64. # Article-specific meta (LinkedIn reads these)
  65. article: {
  66. published_time: @post.published_at&.iso8601,
  67. modified_time: @post.updated_at&.iso8601,
  68. author: @post.author_name.presence || "Gleania Team",
  69. section: "Interview Tips",
  70. tag: @post.tag_list.to_a
  71. },
  72. # Twitter Card
  73. twitter: {
  74. card: "summary_large_image",
  75. site: "@GleaniaApp",
  76. creator: "@GleaniaApp",
  77. title: @post.title,
  78. description: description,
  79. image: og_image
  80. }
  81. )
  82. rescue ActiveRecord::RecordNotFound
  83. redirect_to blog_index_path, alert: "Post not found."
  84. end
  85. end
  86. end

app/controllers/public/blog_tags_controller.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Controller for public tag pages (blog posts filtered by a tag).
  4. class BlogTagsController < BaseController
  5. # GET /blog/tags/:tag
  6. def show
  7. tag_param = params[:tag].to_s
  8. # Find tag by slug (parameterized) or exact name
  9. @tag_record = ActsAsTaggableOn::Tag.find_by("LOWER(name) = ?", tag_param.tr("-", " ").downcase)
  10. @tag_record ||= ActsAsTaggableOn::Tag.find_by(name: tag_param)
  11. if @tag_record.nil?
  12. redirect_to blog_index_path, alert: "Tag not found."
  13. return
  14. end
  15. @tag = @tag_record.name
  16. @posts = BlogPost.published_publicly.tagged_with(@tag).recent_first
  17. if @posts.blank?
  18. redirect_to blog_index_path, alert: "No posts found for this tag."
  19. return
  20. end
  21. # Redirect to canonical slug URL if accessed with spaces or wrong case
  22. canonical_slug = @tag.parameterize
  23. if tag_param != canonical_slug
  24. redirect_to blog_tag_path(canonical_slug), status: :moved_permanently
  25. return
  26. end
  27. set_meta_tags(
  28. title: "Tag: #{@tag}",
  29. description: "Posts tagged with #{@tag} on the Gleania blog.",
  30. canonical: blog_tag_url(canonical_slug),
  31. og: { type: "website", url: blog_tag_url(canonical_slug) }
  32. )
  33. end
  34. end
  35. end

app/controllers/public/contacts_controller.rb

0.0% lines covered

100.0% branches covered

32 relevant lines. 0 lines covered and 32 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Controller for the contact page
  4. #
  5. # Handles the public contact form and inquiries.
  6. class ContactsController < BaseController
  7. # GET /contact
  8. #
  9. # Renders the contact page form.
  10. def show
  11. @support_ticket = SupportTicket.new
  12. end
  13. # POST /contact
  14. #
  15. # Handles contact form submission and creates a support ticket.
  16. def create
  17. # Verify Turnstile token
  18. unless verify_turnstile_token
  19. @support_ticket = SupportTicket.new(support_ticket_params)
  20. @support_ticket.errors.add(:base, "Verification failed. Please try again.")
  21. render :show, status: :unprocessable_entity
  22. return
  23. end
  24. @support_ticket = SupportTicket.new(support_ticket_params)
  25. @support_ticket.user = Current.user if authenticated?
  26. if @support_ticket.save
  27. redirect_to contact_path, notice: "Thank you for your message. We'll be in touch soon!"
  28. else
  29. render :show, status: :unprocessable_entity
  30. end
  31. end
  32. private
  33. # Strong parameters for support ticket
  34. #
  35. # @return [ActionController::Parameters]
  36. def support_ticket_params
  37. params.expect(support_ticket: [ :name, :email, :subject, :message ])
  38. end
  39. # Verifies Turnstile token if configured
  40. #
  41. # @return [Boolean]
  42. def verify_turnstile_token
  43. # Skip verification in development/test environments
  44. return true if Rails.env.development? || Rails.env.test?
  45. # Skip if Turnstile is not fully configured
  46. return true unless turnstile_configured?
  47. token = params["cf-turnstile-response"]
  48. CloudflareTurnstileService.verify(token, request.remote_ip)
  49. end
  50. end
  51. end

app/controllers/public/home_controller.rb

0.0% lines covered

100.0% branches covered

6 relevant lines. 0 lines covered and 6 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Controller for the public homepage
  4. #
  5. # Handles the main marketing landing page for Gleania.
  6. class HomeController < BaseController
  7. # GET /
  8. #
  9. # Renders the public homepage with all marketing sections.
  10. # Authenticated users can optionally be redirected to their dashboard.
  11. def index
  12. # Optionally redirect authenticated users to dashboard
  13. # Uncomment the next line if you want to auto-redirect logged-in users
  14. # redirect_to interview_applications_path if authenticated?
  15. end
  16. end
  17. end

app/controllers/public/legal_controller.rb

0.0% lines covered

100.0% branches covered

14 relevant lines. 0 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Controller for public legal pages (Privacy, Terms, Cookies).
  4. #
  5. # These pages are required for OAuth verification and are accessible without authentication.
  6. class LegalController < BaseController
  7. # GET /privacy
  8. def privacy
  9. set_meta_tags(title: "Privacy Policy", canonical: privacy_url)
  10. end
  11. # GET /terms
  12. def terms
  13. set_meta_tags(title: "Terms of Service", canonical: terms_url)
  14. end
  15. # GET /cookies
  16. #
  17. # Named `cookies_policy` to avoid colliding with ActionController's `cookies` accessor.
  18. def cookies_policy
  19. set_meta_tags(title: "Cookie Policy", canonical: cookies_url)
  20. render :cookies
  21. end
  22. end
  23. end

app/controllers/public/newsletter_subscriptions_controller.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Newsletter capture endpoints (public).
  4. class NewsletterSubscriptionsController < BaseController
  5. # POST /newsletter/subscribe
  6. def create
  7. email = params[:email].to_s.strip
  8. if email.blank?
  9. redirect_back fallback_location: blog_index_path, alert: "Please enter an email."
  10. return
  11. end
  12. subscriber = NewsletterSubscriber.find_or_initialize_by(email: email)
  13. subscriber.save!
  14. Mailkick::Subscription.find_or_create_by!(subscriber: subscriber, list: "newsletter")
  15. redirect_back fallback_location: blog_index_path, notice: "Thanks! You're subscribed."
  16. rescue ActiveRecord::RecordInvalid
  17. redirect_back fallback_location: blog_index_path, alert: "Please enter a valid email."
  18. end
  19. # GET /newsletter/unsubscribe/:signed_id
  20. def destroy
  21. subscriber = NewsletterSubscriber.find_signed!(params[:signed_id])
  22. Mailkick::Subscription.where(subscriber: subscriber, list: "newsletter").delete_all
  23. redirect_to blog_index_path, notice: "You have been unsubscribed."
  24. rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
  25. redirect_to blog_index_path, alert: "Invalid unsubscribe link."
  26. end
  27. end
  28. end

app/controllers/public/pricing_controller.rb

0.0% lines covered

100.0% branches covered

7 relevant lines. 0 lines covered and 7 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Public pricing page.
  4. class PricingController < BaseController
  5. # GET /pricing
  6. def show
  7. @plans = Billing::Catalog.published_plans
  8. end
  9. end
  10. end

app/controllers/public/sitemaps_controller.rb

0.0% lines covered

100.0% branches covered

14 relevant lines. 0 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Public
  3. # Controller for generating a basic sitemap.xml.
  4. class SitemapsController < BaseController
  5. # GET /sitemap.xml
  6. def show
  7. @posts = BlogPost.published_publicly.select(:slug, :updated_at)
  8. @tags = ActsAsTaggableOn::Tag.joins(:taggings)
  9. .where(taggings: { taggable_type: "BlogPost" })
  10. .distinct
  11. .pluck(:name)
  12. respond_to do |format|
  13. format.xml
  14. end
  15. end
  16. end
  17. end

app/controllers/registrations_controller.rb

0.0% lines covered

100.0% branches covered

34 relevant lines. 0 lines covered and 34 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class RegistrationsController < ApplicationController
  2. allow_unauthenticated_access
  3. rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_registration_path, alert: "Try again later." }
  4. layout "authentication"
  5. # GET /registrations/new
  6. # Show registration form
  7. def new
  8. redirect_to root_path, alert: "Sign up is disabled." unless Setting.user_sign_up_enabled?
  9. @user = User.new
  10. end
  11. # POST /registrations
  12. # Create new user account
  13. def create
  14. # Verify Turnstile token
  15. unless verify_turnstile_token
  16. @user = User.new(registration_params)
  17. @user.errors.add(:base, "Verification failed. Please try again.")
  18. render :new, status: :unprocessable_entity
  19. return
  20. end
  21. @user = User.new(registration_params)
  22. if @user.save
  23. # Send verification email
  24. UserMailer.verify_email(@user).deliver_later
  25. redirect_to new_session_path, notice: "Account created! Please check your email to verify your account."
  26. else
  27. render :new, status: :unprocessable_entity
  28. end
  29. end
  30. private
  31. def registration_params
  32. params.expect(user: [ :email_address, :password, :password_confirmation, :name, :terms_accepted, :marketing_opt_in ])
  33. end
  34. # Verifies Turnstile token if configured
  35. #
  36. # @return [Boolean]
  37. def verify_turnstile_token
  38. return true if Rails.env.development? || Rails.env.test?
  39. return true unless turnstile_configured?
  40. token = params["cf-turnstile-response"]
  41. CloudflareTurnstileService.verify(token, request.remote_ip)
  42. end
  43. end

app/controllers/resume_skills_controller.rb

0.0% lines covered

100.0% branches covered

128 relevant lines. 0 lines covered and 128 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing individual skills extracted from resumes
  3. #
  4. # Handles user adjustments to skill levels and skill management (delete, merge)
  5. class ResumeSkillsController < ApplicationController
  6. before_action :set_user_resume
  7. before_action :set_resume_skill, only: [ :update, :destroy ]
  8. # PATCH /resumes/:user_resume_id/skills/:id
  9. #
  10. # Update user-confirmed proficiency level for a skill
  11. def update
  12. respond_to do |format|
  13. if @resume_skill.update(resume_skill_params)
  14. format.html { redirect_to user_resume_path(@user_resume), notice: "Skill updated." }
  15. format.turbo_stream do
  16. render turbo_stream: turbo_stream.replace(
  17. "skill_row_#{@resume_skill.id}",
  18. partial: "resume_skills/skill_row",
  19. locals: { resume_skill: @resume_skill }
  20. )
  21. end
  22. format.json { render json: { success: true, skill: skill_json(@resume_skill) } }
  23. else
  24. format.html { redirect_to user_resume_path(@user_resume), alert: "Could not update skill." }
  25. format.turbo_stream do
  26. render turbo_stream: turbo_stream.update(
  27. "flash",
  28. partial: "shared/flash",
  29. locals: { flash: { alert: @resume_skill.errors.full_messages.join(", ") } }
  30. )
  31. end
  32. format.json { render json: { success: false, errors: @resume_skill.errors.full_messages }, status: :unprocessable_entity }
  33. end
  34. end
  35. end
  36. # DELETE /resumes/:user_resume_id/skills/:id
  37. #
  38. # Remove an irrelevant skill from the resume
  39. def destroy
  40. skill_name = @resume_skill.skill_name
  41. @resume_skill.destroy!
  42. respond_to do |format|
  43. format.html { redirect_to user_resume_path(@user_resume), notice: "#{skill_name} removed." }
  44. format.turbo_stream do
  45. render turbo_stream: [
  46. turbo_stream.remove("skill_row_#{params[:id]}"),
  47. turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "#{skill_name} removed." } })
  48. ]
  49. end
  50. format.json { render json: { success: true } }
  51. end
  52. end
  53. # POST /resumes/:user_resume_id/skills/merge
  54. #
  55. # Merge duplicate skills (e.g., "PostgreSQL" and "Postgres")
  56. def merge
  57. source_skill_id = params[:source_skill_id]
  58. target_skill_id = params[:target_skill_id]
  59. source_skill = SkillTag.find_by(id: source_skill_id)
  60. target_skill = SkillTag.find_by(id: target_skill_id)
  61. unless source_skill && target_skill
  62. respond_to do |format|
  63. format.html { redirect_to user_resume_path(@user_resume), alert: "Skills not found." }
  64. format.json { render json: { success: false, error: "Skills not found" }, status: :not_found }
  65. end
  66. return
  67. end
  68. if SkillTag.merge_skills(source_skill, target_skill)
  69. # Re-aggregate skills for the user
  70. Resumes::SkillAggregationService.new(Current.user).aggregate_skill(target_skill)
  71. respond_to do |format|
  72. format.html { redirect_to user_resume_path(@user_resume), notice: "Skills merged successfully." }
  73. format.turbo_stream do
  74. @resume_skills = @user_resume.resume_skills.includes(:skill_tag).order(Arel.sql("COALESCE(user_level, model_level) DESC"))
  75. render turbo_stream: [
  76. turbo_stream.update("skills_list", partial: "resume_skills/skills_list", locals: { resume_skills: @resume_skills }),
  77. turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "Skills merged successfully." } })
  78. ]
  79. end
  80. format.json { render json: { success: true } }
  81. end
  82. else
  83. respond_to do |format|
  84. format.html { redirect_to user_resume_path(@user_resume), alert: "Could not merge skills." }
  85. format.json { render json: { success: false, error: "Merge failed" }, status: :unprocessable_entity }
  86. end
  87. end
  88. end
  89. # POST /resumes/:user_resume_id/skills/bulk_update
  90. #
  91. # Update multiple skills at once (after review)
  92. def bulk_update
  93. skills_data = params[:skills] || []
  94. updated_count = 0
  95. skills_data.each do |skill_data|
  96. resume_skill = @user_resume.resume_skills.find_by(id: skill_data[:id])
  97. next unless resume_skill
  98. if resume_skill.update(user_level: skill_data[:user_level])
  99. updated_count += 1
  100. end
  101. end
  102. respond_to do |format|
  103. format.html { redirect_to user_resume_path(@user_resume), notice: "#{updated_count} skills updated." }
  104. format.turbo_stream do
  105. @resume_skills = @user_resume.resume_skills.includes(:skill_tag).order(Arel.sql("COALESCE(user_level, model_level) DESC"))
  106. render turbo_stream: [
  107. turbo_stream.update("skills_list", partial: "resume_skills/skills_list", locals: { resume_skills: @resume_skills }),
  108. turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "#{updated_count} skills updated." } })
  109. ]
  110. end
  111. format.json { render json: { success: true, updated_count: updated_count } }
  112. end
  113. end
  114. private
  115. # Sets the parent resume
  116. #
  117. # @return [UserResume]
  118. def set_user_resume
  119. @user_resume = Current.user.user_resumes.find(params[:user_resume_id])
  120. rescue ActiveRecord::RecordNotFound
  121. respond_to do |format|
  122. format.html { redirect_to user_resumes_path, alert: "Resume not found." }
  123. format.json { render json: { error: "Not found" }, status: :not_found }
  124. end
  125. end
  126. # Sets the resume skill for member actions
  127. #
  128. # @return [ResumeSkill]
  129. def set_resume_skill
  130. @resume_skill = @user_resume.resume_skills.find(params[:id])
  131. rescue ActiveRecord::RecordNotFound
  132. respond_to do |format|
  133. format.html { redirect_to user_resume_path(@user_resume), alert: "Skill not found." }
  134. format.json { render json: { error: "Not found" }, status: :not_found }
  135. end
  136. end
  137. # Strong parameters for skill updates
  138. #
  139. # @return [ActionController::Parameters]
  140. def resume_skill_params
  141. params.expect(resume_skill: [ :user_level ])
  142. end
  143. # Builds JSON representation of a skill
  144. #
  145. # @param resume_skill [ResumeSkill]
  146. # @return [Hash]
  147. def skill_json(resume_skill)
  148. {
  149. id: resume_skill.id,
  150. skill_name: resume_skill.skill_name,
  151. model_level: resume_skill.model_level,
  152. user_level: resume_skill.user_level,
  153. effective_level: resume_skill.effective_level,
  154. confidence_score: resume_skill.confidence_score,
  155. category: resume_skill.category
  156. }
  157. end
  158. end

app/controllers/saved_jobs_controller.rb

0.0% lines covered

100.0% branches covered

78 relevant lines. 0 lines covered and 78 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing Saved Jobs (bookmarked job leads).
  3. #
  4. # Supports saving from an Opportunity or from a pasted URL, and converting a saved job
  5. # into an InterviewApplication using existing services.
  6. class SavedJobsController < ApplicationController
  7. before_action :set_saved_job, only: [ :destroy, :convert ]
  8. before_action :set_saved_job_any_status, only: [ :restore ]
  9. # GET /saved_jobs
  10. def index
  11. @saved_jobs = Current.user.saved_jobs.active
  12. .includes(:opportunity, :fit_assessment)
  13. .recent
  14. end
  15. # POST /saved_jobs
  16. def create
  17. @saved_job = build_saved_job_from_params
  18. if @saved_job.save
  19. redirect_back fallback_location: saved_jobs_path, notice: "Saved."
  20. else
  21. redirect_back fallback_location: saved_jobs_path, alert: @saved_job.errors.full_messages.to_sentence
  22. end
  23. end
  24. # DELETE /saved_jobs/:id
  25. def destroy
  26. if @saved_job.archive_removed!
  27. redirect_back fallback_location: saved_jobs_path, notice: "Removed."
  28. else
  29. redirect_back fallback_location: saved_jobs_path, alert: "Could not remove."
  30. end
  31. end
  32. # POST /saved_jobs/:id/restore
  33. def restore
  34. if @saved_job.restore!
  35. redirect_back fallback_location: saved_jobs_path, notice: "Restored."
  36. else
  37. redirect_back fallback_location: saved_jobs_path, alert: "Could not restore."
  38. end
  39. end
  40. # POST /saved_jobs/:id/convert
  41. def convert
  42. result = convert_saved_job(@saved_job)
  43. if result[:success]
  44. @saved_job.update!(converted_at: Time.current) if @saved_job.converted_at.blank?
  45. redirect_to result[:application], notice: "Application created."
  46. else
  47. redirect_back fallback_location: saved_jobs_path, alert: result[:error] || "Could not convert saved job."
  48. end
  49. end
  50. private
  51. def set_saved_job
  52. @saved_job = Current.user.saved_jobs.active.find(params[:id])
  53. end
  54. def set_saved_job_any_status
  55. @saved_job = Current.user.saved_jobs.find(params[:id])
  56. end
  57. def saved_job_params
  58. params.expect(saved_job: [ :url, :notes, :opportunity_id ])
  59. end
  60. def build_saved_job_from_params
  61. attrs = saved_job_params
  62. if attrs[:opportunity_id].present?
  63. opportunity = Current.user.opportunities.find(attrs[:opportunity_id])
  64. Current.user.saved_jobs.new(
  65. opportunity: opportunity,
  66. url: nil,
  67. company_name: opportunity.company_name,
  68. job_role_title: opportunity.job_role_title,
  69. title: opportunity.job_role_title,
  70. notes: attrs[:notes]
  71. )
  72. else
  73. Current.user.saved_jobs.new(
  74. url: attrs[:url]&.strip,
  75. notes: attrs[:notes]
  76. )
  77. end
  78. end
  79. def convert_saved_job(saved_job)
  80. if saved_job.opportunity.present?
  81. Opportunities::CreateApplicationService.new(saved_job.opportunity, Current.user).call
  82. else
  83. url = saved_job.effective_url
  84. return { success: false, error: "URL is missing" } if url.blank?
  85. QuickApplyFromUrlService.new(url, Current.user).call
  86. end
  87. end
  88. end

app/controllers/sessions_controller.rb

0.0% lines covered

100.0% branches covered

29 relevant lines. 0 lines covered and 29 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class SessionsController < ApplicationController
  2. allow_unauthenticated_access only: %i[ new create ]
  3. rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
  4. layout "authentication"
  5. def new
  6. redirect_to root_path, alert: "Sign in is disabled." unless Setting.user_login_enabled?
  7. end
  8. def create
  9. unless Setting.username_password_login_enabled?
  10. redirect_to new_session_path, alert: "Username and password login is disabled."
  11. return
  12. end
  13. if user = User.authenticate_by(params.permit(:email_address, :password))
  14. if user.email_verified?
  15. start_new_session_for user
  16. redirect_to after_authentication_url
  17. else
  18. redirect_to new_email_verification_path(email_address: user.email_address),
  19. alert: "Please verify your email first. You can request a new verification link below."
  20. end
  21. else
  22. redirect_to new_session_path, alert: "Try another email address or password."
  23. end
  24. end
  25. def destroy
  26. terminate_session
  27. redirect_to new_session_path, status: :see_other
  28. end
  29. end

app/controllers/settings_controller.rb

0.0% lines covered

100.0% branches covered

502 relevant lines. 0 lines covered and 502 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for user settings management
  3. # Handles profile, preferences, notifications, AI settings, integrations, privacy, security,
  4. # work experience, and targets
  5. class SettingsController < ApplicationController
  6. before_action :set_user
  7. before_action :set_preference
  8. before_action :load_profile_data, only: [ :show, :update_profile ]
  9. before_action :set_work_experience, only: [ :update_work_experience, :destroy_work_experience ]
  10. # GET /settings
  11. def show
  12. @active_tab = params[:tab] || "profile"
  13. @sessions = @user.sessions.order(created_at: :desc) if @active_tab == "security"
  14. @connected_accounts = @user.connected_accounts if @active_tab == "integrations" && @user.respond_to?(:connected_accounts)
  15. load_subscription_data if @active_tab == "subscription"
  16. load_billing_data if @active_tab == "billing"
  17. load_work_experience_data if @active_tab == "work_experience"
  18. load_targets_data if @active_tab == "targets"
  19. end
  20. # PATCH /settings/profile
  21. def update_profile
  22. if @user.update(profile_params)
  23. respond_to_update("profile", "Profile updated successfully.")
  24. else
  25. respond_to_error("profile")
  26. end
  27. end
  28. # PATCH /settings/general
  29. def update_general
  30. if @preference.update(general_params)
  31. respond_to_update("general", "General settings updated successfully.")
  32. else
  33. respond_to_error("general")
  34. end
  35. end
  36. # PATCH /settings/notifications
  37. def update_notifications
  38. if @preference.update(notification_params)
  39. respond_to_update("notifications", "Notification settings updated successfully.")
  40. else
  41. respond_to_error("notifications")
  42. end
  43. end
  44. # PATCH /settings/ai_preferences
  45. def update_ai_preferences
  46. if @preference.update(ai_preference_params)
  47. respond_to_update("ai_preferences", "AI preferences updated successfully.")
  48. else
  49. respond_to_error("ai_preferences")
  50. end
  51. end
  52. # PATCH /settings/privacy
  53. def update_privacy
  54. if @preference.update(privacy_params)
  55. respond_to_update("privacy", "Privacy settings updated successfully.")
  56. else
  57. respond_to_error("privacy")
  58. end
  59. end
  60. # PATCH /settings/security
  61. def update_security
  62. if @user.update(security_params)
  63. redirect_to settings_path(tab: "security"), notice: "Security settings updated successfully."
  64. else
  65. @active_tab = "security"
  66. @sessions = @user.sessions.order(created_at: :desc)
  67. render :show, status: :unprocessable_entity
  68. end
  69. end
  70. # DELETE /settings/sessions/:id
  71. def destroy_session
  72. session_to_destroy = @user.sessions.find_by(id: params[:session_id])
  73. if session_to_destroy
  74. is_current = session_to_destroy.id == Current.session&.id
  75. session_to_destroy.destroy
  76. if is_current
  77. redirect_to new_session_path, notice: "You have been signed out.", status: :see_other
  78. else
  79. redirect_to settings_path(tab: "security"), notice: "Session revoked successfully.", status: :see_other
  80. end
  81. else
  82. redirect_to settings_path(tab: "security"), alert: "Session not found.", status: :see_other
  83. end
  84. end
  85. # DELETE /settings/sessions
  86. def destroy_all_sessions
  87. @user.sessions.where.not(id: Current.session&.id).destroy_all
  88. redirect_to settings_path(tab: "security"), notice: "All other sessions have been signed out.", status: :see_other
  89. end
  90. # DELETE /settings/disconnect/:provider
  91. def disconnect_provider
  92. return redirect_to settings_path(tab: "integrations"), alert: "Provider not specified." unless params[:provider].present?
  93. # If account_id is provided, disconnect that specific account
  94. # Otherwise, disconnect the first account found (for backward compatibility)
  95. if params[:account_id].present?
  96. account = @user.connected_accounts.find_by(id: params[:account_id], provider: params[:provider])
  97. else
  98. account = @user.connected_accounts.find_by(provider: params[:provider])
  99. end
  100. if account&.destroy
  101. redirect_to settings_path(tab: "integrations"), notice: "#{params[:provider].titleize} account (#{account.email}) disconnected.", status: :see_other
  102. else
  103. redirect_to settings_path(tab: "integrations"), alert: "Could not disconnect account.", status: :see_other
  104. end
  105. end
  106. # POST /settings/export_data
  107. def export_data
  108. # Queue a background job to generate the export
  109. # For now, we'll redirect with a notice
  110. redirect_to settings_path(tab: "privacy"), notice: "Data export has been queued. You will receive an email when it's ready."
  111. end
  112. # DELETE /settings/account
  113. def destroy_account
  114. if @user.authenticate(params[:password])
  115. @user.destroy
  116. reset_session
  117. redirect_to new_session_path, notice: "Your account has been permanently deleted.", status: :see_other
  118. else
  119. redirect_to settings_path(tab: "privacy"), alert: "Incorrect password. Account deletion cancelled."
  120. end
  121. end
  122. # POST /settings/trigger_sync
  123. def trigger_sync
  124. # If account_id is provided, sync that specific account
  125. # Otherwise, sync the first Google account (for backward compatibility)
  126. if params[:account_id].present?
  127. account = @user.connected_accounts.find_by(id: params[:account_id], provider: "google_oauth2")
  128. else
  129. account = @user.google_account
  130. end
  131. if account.nil?
  132. redirect_to settings_path(tab: "integrations"), alert: "No Gmail account connected."
  133. return
  134. end
  135. # Queue the sync job
  136. GmailSyncJob.perform_later(@user, connected_account: account)
  137. redirect_to settings_path(tab: "integrations"), notice: "Email sync started for #{account.email}. This may take a few moments."
  138. end
  139. # PATCH /settings/toggle_sync
  140. def toggle_sync
  141. # If account_id is provided, toggle sync for that specific account
  142. # Otherwise, toggle sync for the first Google account (for backward compatibility)
  143. if params[:account_id].present?
  144. account = @user.connected_accounts.find_by(id: params[:account_id], provider: "google_oauth2")
  145. else
  146. account = @user.google_account
  147. end
  148. if account.nil?
  149. respond_to do |format|
  150. format.json { render json: { success: false, error: "No Gmail account connected" }, status: :unprocessable_entity }
  151. format.html { redirect_to settings_path(tab: "integrations"), alert: "No Gmail account connected." }
  152. end
  153. return
  154. end
  155. # Toggle sync_enabled based on checkbox value
  156. sync_enabled = params[:sync_enabled] == "1"
  157. account.update!(sync_enabled: sync_enabled)
  158. respond_to do |format|
  159. format.json { render json: { success: true, sync_enabled: account.sync_enabled? }, status: :ok }
  160. format.html { redirect_to settings_path(tab: "integrations"), notice: "Sync settings updated for #{account.email}." }
  161. end
  162. end
  163. # =================================================================
  164. # Work Experience Actions
  165. # =================================================================
  166. # POST /settings/work_experience
  167. # Creates a new manual work experience entry
  168. def create_work_experience
  169. @work_experience = @user.user_work_experiences.build(work_experience_params)
  170. @work_experience.source_type = :manual
  171. if @work_experience.save
  172. respond_to do |format|
  173. format.html { redirect_to settings_path(tab: "work_experience"), notice: "Work experience added successfully." }
  174. format.json { render json: { success: true, work_experience: work_experience_json(@work_experience) }, status: :created }
  175. format.turbo_stream { load_work_experience_data }
  176. end
  177. else
  178. respond_to do |format|
  179. format.html do
  180. @active_tab = "work_experience"
  181. load_work_experience_data
  182. render :show, status: :unprocessable_entity
  183. end
  184. format.json { render json: { success: false, errors: @work_experience.errors.full_messages }, status: :unprocessable_entity }
  185. end
  186. end
  187. end
  188. # PATCH /settings/work_experience/:id
  189. # Updates a work experience entry (both AI-extracted and manual)
  190. def update_work_experience
  191. if @work_experience.update(work_experience_params)
  192. respond_to do |format|
  193. format.html { redirect_to settings_path(tab: "work_experience"), notice: "Work experience updated successfully." }
  194. format.json { render json: { success: true, work_experience: work_experience_json(@work_experience) }, status: :ok }
  195. format.turbo_stream { load_work_experience_data }
  196. end
  197. else
  198. respond_to do |format|
  199. format.html do
  200. @active_tab = "work_experience"
  201. load_work_experience_data
  202. render :show, status: :unprocessable_entity
  203. end
  204. format.json { render json: { success: false, errors: @work_experience.errors.full_messages }, status: :unprocessable_entity }
  205. end
  206. end
  207. end
  208. # DELETE /settings/work_experience/:id
  209. # Deletes a work experience entry
  210. def destroy_work_experience
  211. @work_experience.destroy
  212. respond_to do |format|
  213. format.html { redirect_to settings_path(tab: "work_experience"), notice: "Work experience deleted.", status: :see_other }
  214. format.json { render json: { success: true }, status: :ok }
  215. format.turbo_stream { load_work_experience_data }
  216. end
  217. end
  218. # =================================================================
  219. # Targets Actions
  220. # =================================================================
  221. # PATCH /settings/targets
  222. # Updates user's target roles, companies, and domains
  223. def update_targets
  224. ActiveRecord::Base.transaction do
  225. update_target_job_roles if params[:target_job_role_ids]
  226. update_target_companies if params[:target_company_ids]
  227. update_target_domains if params[:target_domain_ids]
  228. end
  229. respond_to do |format|
  230. format.html { redirect_to settings_path(tab: "targets"), notice: "Targets updated successfully." }
  231. format.json { render json: { success: true }, status: :ok }
  232. format.turbo_stream { load_targets_data }
  233. end
  234. rescue ActiveRecord::RecordInvalid => e
  235. respond_to do |format|
  236. format.html { redirect_to settings_path(tab: "targets"), alert: "Failed to update targets: #{e.message}" }
  237. format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
  238. end
  239. end
  240. # POST /settings/targets/add_role
  241. # Adds a single target role
  242. def add_target_role
  243. job_role = JobRole.find(params[:job_role_id])
  244. @user.user_target_job_roles.find_or_create_by!(job_role: job_role)
  245. respond_to do |format|
  246. format.html { redirect_to settings_path(tab: "targets"), notice: "#{job_role.title} added to target roles." }
  247. format.json { render json: { success: true, job_role: { id: job_role.id, title: job_role.title, department: job_role.department_name } }, status: :ok }
  248. end
  249. rescue ActiveRecord::RecordNotFound
  250. respond_to do |format|
  251. format.html { redirect_to settings_path(tab: "targets"), alert: "Role not found." }
  252. format.json { render json: { success: false, error: "Role not found" }, status: :not_found }
  253. end
  254. rescue ActiveRecord::RecordInvalid => e
  255. respond_to do |format|
  256. format.html { redirect_to settings_path(tab: "targets"), alert: e.message }
  257. format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
  258. end
  259. end
  260. # DELETE /settings/targets/remove_role
  261. # Removes a single target role
  262. def remove_target_role
  263. @user.user_target_job_roles.where(job_role_id: params[:job_role_id]).destroy_all
  264. respond_to do |format|
  265. format.html { redirect_to settings_path(tab: "targets"), notice: "Role removed from targets.", status: :see_other }
  266. format.json { render json: { success: true }, status: :ok }
  267. end
  268. end
  269. # POST /settings/targets/add_company
  270. # Adds a single target company
  271. def add_target_company
  272. company = Company.find(params[:company_id])
  273. @user.user_target_companies.find_or_create_by!(company: company)
  274. respond_to do |format|
  275. format.html { redirect_to settings_path(tab: "targets"), notice: "#{company.name} added to target companies." }
  276. format.json { render json: { success: true, company: { id: company.id, name: company.name } }, status: :ok }
  277. end
  278. rescue ActiveRecord::RecordNotFound
  279. respond_to do |format|
  280. format.html { redirect_to settings_path(tab: "targets"), alert: "Company not found." }
  281. format.json { render json: { success: false, error: "Company not found" }, status: :not_found }
  282. end
  283. rescue ActiveRecord::RecordInvalid => e
  284. respond_to do |format|
  285. format.html { redirect_to settings_path(tab: "targets"), alert: e.message }
  286. format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
  287. end
  288. end
  289. # DELETE /settings/targets/remove_company
  290. # Removes a single target company
  291. def remove_target_company
  292. @user.user_target_companies.where(company_id: params[:company_id]).destroy_all
  293. respond_to do |format|
  294. format.html { redirect_to settings_path(tab: "targets"), notice: "Company removed from targets.", status: :see_other }
  295. format.json { render json: { success: true }, status: :ok }
  296. end
  297. end
  298. # POST /settings/targets/add_domain
  299. # Adds a single target domain
  300. def add_target_domain
  301. domain = Domain.find(params[:domain_id])
  302. @user.user_target_domains.find_or_create_by!(domain: domain)
  303. respond_to do |format|
  304. format.html { redirect_to settings_path(tab: "targets"), notice: "#{domain.name} added to target domains." }
  305. format.json { render json: { success: true, domain: { id: domain.id, name: domain.name } }, status: :ok }
  306. end
  307. rescue ActiveRecord::RecordNotFound
  308. respond_to do |format|
  309. format.html { redirect_to settings_path(tab: "targets"), alert: "Domain not found." }
  310. format.json { render json: { success: false, error: "Domain not found" }, status: :not_found }
  311. end
  312. rescue ActiveRecord::RecordInvalid => e
  313. respond_to do |format|
  314. format.html { redirect_to settings_path(tab: "targets"), alert: e.message }
  315. format.json { render json: { success: false, error: e.message }, status: :unprocessable_entity }
  316. end
  317. end
  318. # DELETE /settings/targets/remove_domain
  319. # Removes a single target domain
  320. def remove_target_domain
  321. @user.user_target_domains.where(domain_id: params[:domain_id]).destroy_all
  322. respond_to do |format|
  323. format.html { redirect_to settings_path(tab: "targets"), notice: "Domain removed from targets.", status: :see_other }
  324. format.json { render json: { success: true }, status: :ok }
  325. end
  326. end
  327. private
  328. # Responds to successful update - JSON for AJAX, redirect for regular requests
  329. # @param tab [String] The tab name
  330. # @param message [String] Success message
  331. def respond_to_update(tab, message)
  332. respond_to do |format|
  333. format.json { render json: { success: true, message: message }, status: :ok }
  334. format.html { redirect_to settings_path(tab: tab), notice: message }
  335. format.any { render json: { success: true, message: message }, status: :ok }
  336. end
  337. end
  338. # Responds to failed update - JSON for AJAX, render form for regular requests
  339. # @param tab [String] The tab name
  340. def respond_to_error(tab)
  341. @active_tab = tab
  342. errors = tab == "profile" ? @user.errors.full_messages : @preference.errors.full_messages
  343. respond_to do |format|
  344. format.json { render json: { success: false, errors: errors }, status: :unprocessable_entity }
  345. format.html { render :show, status: :unprocessable_entity }
  346. format.any { render json: { success: false, errors: errors }, status: :unprocessable_entity }
  347. end
  348. end
  349. # Sets the current user
  350. # @return [User]
  351. def set_user
  352. @user = Current.user
  353. end
  354. # Sets or builds the user preference
  355. # @return [UserPreference]
  356. def set_preference
  357. @preference = @user.preference || @user.build_preference
  358. end
  359. # Strong parameters for general settings
  360. # @return [ActionController::Parameters]
  361. def general_params
  362. params.expect(user_preference: [ :theme, :timezone, :preferred_view ])
  363. end
  364. # Strong parameters for notification settings
  365. # @return [ActionController::Parameters]
  366. def notification_params
  367. params.expect(user_preference: [
  368. :email_notifications,
  369. :email_weekly_digest,
  370. :email_interview_reminders
  371. ])
  372. end
  373. # Strong parameters for AI preference settings
  374. # @return [ActionController::Parameters]
  375. def ai_preference_params
  376. params.expect(user_preference: [
  377. :ai_summary_enabled,
  378. :ai_feedback_analysis,
  379. :ai_interview_prep,
  380. :ai_insights_frequency
  381. ])
  382. end
  383. # Strong parameters for privacy settings
  384. # @return [ActionController::Parameters]
  385. def privacy_params
  386. params.expect(user_preference: [ :data_retention_days ])
  387. end
  388. # Strong parameters for security settings (password change)
  389. # @return [ActionController::Parameters]
  390. def security_params
  391. params.expect(user: [ :password, :password_confirmation ])
  392. end
  393. # Strong parameters for profile settings
  394. # @return [ActionController::Parameters]
  395. def profile_params
  396. params.require(:user).permit(
  397. :name,
  398. :bio,
  399. :current_job_role_id,
  400. :current_company_id,
  401. :years_of_experience,
  402. :linkedin_url,
  403. :github_url,
  404. :gitlab_url,
  405. :twitter_url,
  406. :portfolio_url
  407. )
  408. end
  409. # Loads companies and job roles for the profile tab
  410. # @return [void]
  411. def load_profile_data
  412. @companies = Company.alphabetical.limit(100)
  413. @job_roles = JobRole.alphabetical.limit(100)
  414. end
  415. # Loads subscription data for the subscription tab
  416. # @return [void]
  417. def load_subscription_data
  418. @entitlements = Billing::Entitlements.for(@user)
  419. @plans = Billing::Catalog.published_plans
  420. load_billing_action_urls
  421. end
  422. # Loads billing data for the billing tab
  423. # @return [void]
  424. def load_billing_data
  425. @billing_customer = @user.billing_customers.find_by(provider: "lemonsqueezy")
  426. @billing_history = load_billing_history
  427. load_billing_action_urls
  428. load_payment_method_info
  429. end
  430. # Loads payment method info from the latest subscription with card details.
  431. #
  432. # @return [void]
  433. def load_payment_method_info
  434. subscription_with_card = @user.billing_subscriptions
  435. .where(provider: "lemonsqueezy")
  436. .where.not(card_brand: nil)
  437. .order(updated_at: :desc)
  438. .first
  439. @payment_method = if subscription_with_card&.card_brand.present?
  440. {
  441. card_brand: subscription_with_card.card_brand,
  442. card_last_four: subscription_with_card.card_last_four
  443. }
  444. end
  445. end
  446. # Loads billing history using orders as the source of truth.
  447. #
  448. # Each order represents a payment. Orders linked to subscriptions show invoice button,
  449. # one-time orders (Sprint) only show receipt button.
  450. #
  451. # @return [Array<Hash>]
  452. def load_billing_history
  453. orders = @user.billing_orders
  454. .includes(:subscription)
  455. .where(provider: "lemonsqueezy")
  456. .where(status: "paid")
  457. .order(created_at: :desc)
  458. .limit(15)
  459. @subscription_history = []
  460. @order_history = []
  461. history = orders.map do |order|
  462. entry = build_billing_entry_from_order(order)
  463. if order.subscription.present?
  464. @subscription_history << entry
  465. else
  466. @order_history << entry
  467. end
  468. entry
  469. end
  470. history.sort_by { |e| e[:date] || Time.at(0) }.reverse
  471. end
  472. # Builds a billing history entry from an order.
  473. #
  474. # @param order [Billing::Order]
  475. # @return [Hash]
  476. def build_billing_entry_from_order(order)
  477. subscription = order.subscription
  478. plan = subscription&.plan || resolve_plan_for_order(order)
  479. is_one_time = plan&.one_time? || subscription.nil?
  480. # Get product name from order data (actual purchase), fallback to plan name
  481. product_name = order.metadata&.dig("raw", "first_order_item", "product_name") ||
  482. plan&.name ||
  483. "Payment"
  484. {
  485. type: is_one_time ? :order : :subscription,
  486. date: order.created_at,
  487. plan_name: product_name,
  488. amount: (order.total_cents || 0) / 100.0,
  489. currency: order.currency || "usd",
  490. status: order.status,
  491. order_id: order.external_order_id,
  492. order_number: order.order_number,
  493. receipt_url: order.receipt_url,
  494. invoice_url: subscription&.latest_invoice_url, # Only subscriptions have invoices
  495. is_one_time: is_one_time
  496. }
  497. end
  498. # Resolves the plan for an order based on variant_id in metadata.
  499. #
  500. # @param order [Billing::Order]
  501. # @return [Billing::Plan, nil]
  502. def resolve_plan_for_order(order)
  503. variant_id = order.metadata&.dig("raw", "first_order_item", "variant_id")
  504. return nil if variant_id.blank?
  505. mapping = Billing::ProviderMapping.find_by(provider: "lemonsqueezy", external_variant_id: variant_id.to_s)
  506. mapping&.plan
  507. end
  508. # Loads billing action URLs from stored metadata.
  509. #
  510. # @return [void]
  511. def load_billing_action_urls
  512. @billing_customer ||= @user.billing_customers.find_by(provider: "lemonsqueezy")
  513. subscription = latest_billing_subscription
  514. @billing_portal_url = @billing_customer&.customer_portal_url || subscription&.customer_portal_url
  515. @billing_update_payment_url = subscription&.update_payment_method_url
  516. @billing_update_subscription_url = subscription&.update_subscription_url
  517. end
  518. # Returns the most recently updated billing subscription.
  519. #
  520. # @return [Billing::Subscription, nil]
  521. def latest_billing_subscription
  522. @latest_billing_subscription ||= @user.billing_subscriptions
  523. .where(provider: "lemonsqueezy")
  524. .order(updated_at: :desc)
  525. .first
  526. end
  527. # =================================================================
  528. # Work Experience Data & Params
  529. # =================================================================
  530. # Sets work experience for update/destroy actions
  531. # @return [UserWorkExperience]
  532. def set_work_experience
  533. @work_experience = @user.user_work_experiences.find(params[:id])
  534. rescue ActiveRecord::RecordNotFound
  535. redirect_to settings_path(tab: "work_experience"), alert: "Work experience not found."
  536. end
  537. # Loads work experience data for the work_experience tab
  538. # @return [void]
  539. def load_work_experience_data
  540. @work_experiences = @user.user_work_experiences
  541. .reverse_chronological
  542. .includes(:company, :job_role, :skill_tags)
  543. @new_work_experience = @user.user_work_experiences.build
  544. @companies = Company.alphabetical.limit(200)
  545. @job_roles = JobRole.alphabetical.limit(200)
  546. end
  547. # Strong parameters for work experience
  548. # @return [ActionController::Parameters]
  549. def work_experience_params
  550. params.require(:user_work_experience).permit(
  551. :company_name,
  552. :role_title,
  553. :company_id,
  554. :job_role_id,
  555. :start_date,
  556. :end_date,
  557. :current,
  558. :responsibilities,
  559. :highlights
  560. )
  561. end
  562. # Serializes work experience for JSON response
  563. # @param work_experience [UserWorkExperience]
  564. # @return [Hash]
  565. def work_experience_json(work_experience)
  566. {
  567. id: work_experience.id,
  568. company_name: work_experience.display_company_name,
  569. role_title: work_experience.display_role_title,
  570. start_date: work_experience.start_date,
  571. end_date: work_experience.end_date,
  572. current: work_experience.current,
  573. source_type: work_experience.source_type,
  574. responsibilities: work_experience.responsibilities,
  575. highlights: work_experience.highlights
  576. }
  577. end
  578. # =================================================================
  579. # Targets Data & Params
  580. # =================================================================
  581. # Loads targets data for the targets tab
  582. # @return [void]
  583. def load_targets_data
  584. @target_job_roles = @user.target_job_roles.includes(:category).alphabetical
  585. @target_companies = @user.target_companies.alphabetical
  586. @target_domains = @user.target_domains.alphabetical
  587. @departments = Category.departments
  588. @job_roles_by_department = JobRole.alphabetical
  589. .includes(:category)
  590. .group_by { |r| r.category&.name || "Uncategorized" }
  591. @all_companies = Company.alphabetical
  592. @all_domains = Domain.alphabetical
  593. end
  594. # Updates target job roles
  595. # @return [void]
  596. def update_target_job_roles
  597. new_ids = Array(params[:target_job_role_ids]).map(&:to_i).reject(&:zero?)
  598. current_ids = @user.target_job_role_ids
  599. # Remove deselected
  600. to_remove = current_ids - new_ids
  601. @user.user_target_job_roles.where(job_role_id: to_remove).destroy_all if to_remove.any?
  602. # Add new
  603. to_add = new_ids - current_ids
  604. to_add.each do |role_id|
  605. @user.user_target_job_roles.find_or_create_by!(job_role_id: role_id)
  606. end
  607. end
  608. # Updates target companies
  609. # @return [void]
  610. def update_target_companies
  611. new_ids = Array(params[:target_company_ids]).map(&:to_i).reject(&:zero?)
  612. current_ids = @user.target_company_ids
  613. # Remove deselected
  614. to_remove = current_ids - new_ids
  615. @user.user_target_companies.where(company_id: to_remove).destroy_all if to_remove.any?
  616. # Add new
  617. to_add = new_ids - current_ids
  618. to_add.each do |company_id|
  619. @user.user_target_companies.find_or_create_by!(company_id: company_id)
  620. end
  621. end
  622. # Updates target domains
  623. # @return [void]
  624. def update_target_domains
  625. new_ids = Array(params[:target_domain_ids]).map(&:to_i).reject(&:zero?)
  626. current_ids = @user.target_domain_ids
  627. # Remove deselected
  628. to_remove = current_ids - new_ids
  629. @user.user_target_domains.where(domain_id: to_remove).destroy_all if to_remove.any?
  630. # Add new
  631. to_add = new_ids - current_ids
  632. to_add.each do |domain_id|
  633. @user.user_target_domains.find_or_create_by!(domain_id: domain_id)
  634. end
  635. end
  636. end

app/controllers/signals_controller.rb

0.0% lines covered

100.0% branches covered

450 relevant lines. 0 lines covered and 450 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for the Signals view (rebranded Inbox)
  3. # Displays synced emails with AI-extracted intelligence and smart actions
  4. # Supports split-pane layout with Turbo Frames
  5. class SignalsController < ApplicationController
  6. before_action :set_synced_email, only: [ :show, :match_application, :ignore, :execute_action ]
  7. # GET /signals
  8. #
  9. # Main signals view with split-pane layout
  10. def index
  11. @emails = current_user_emails
  12. .includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
  13. .order(email_date: :desc)
  14. # Apply relevance filter (default to relevant emails only)
  15. @current_relevance = params[:relevance] || "relevant"
  16. @emails = filter_by_relevance(@emails)
  17. # Apply other filters
  18. @emails = filter_by_type(@emails)
  19. @emails = filter_by_status(@emails)
  20. @emails = filter_by_company(@emails)
  21. @emails = search_emails(@emails)
  22. # For "all" tab, show unified chronological list; otherwise split by matched/unmatched
  23. @show_unified_list = @current_relevance == "all"
  24. if @show_unified_list
  25. # Unified chronological list - group by thread and paginate all together
  26. all_by_thread = group_emails_by_thread(@emails)
  27. @pagy_all, @all_emails = pagy_array(all_by_thread, limit: 20, page_param: :page)
  28. @grouped_emails = {}
  29. @unmatched_emails = []
  30. @pagy_unmatched = nil
  31. else
  32. # Split view: unmatched emails first, then matched grouped by application
  33. @grouped_emails = group_emails_by_application(@emails)
  34. # Get unmatched emails grouped by thread (only latest email per thread)
  35. unmatched_by_thread = group_emails_by_thread(@emails.unmatched)
  36. @pagy_unmatched, @unmatched_emails = pagy_array(unmatched_by_thread, limit: 15, page_param: :unmatched_page)
  37. @all_emails = []
  38. @pagy_all = nil
  39. end
  40. # Load filter options
  41. @email_types = SyncedEmail::EMAIL_TYPES
  42. @companies = Company.joins(:interview_applications)
  43. .where(interview_applications: { user_id: Current.user.id })
  44. .distinct
  45. .alphabetical
  46. # Calculate counts for relevance tabs
  47. @relevance_counts = calculate_relevance_counts
  48. # If email_id param, pre-select that email
  49. @selected_email = current_user_emails.find_by(id: params[:email_id]) if params[:email_id]
  50. # Respond to turbo frame requests for email_list (search/filter without full page reload)
  51. respond_to do |format|
  52. format.html do
  53. if turbo_frame_request_id == "email_list"
  54. render inline: <<~ERB, locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @selected_email&.id, show_unified_list: @show_unified_list, all_emails: @all_emails, pagy_all: @pagy_all }
  55. <%= turbo_frame_tag "email_list", class: "flex-1 overflow-y-auto" do %>
  56. <%= render "signals/email_list", grouped_emails: grouped_emails, unmatched_emails: unmatched_emails, pagy_unmatched: pagy_unmatched, selected_email_id: selected_email_id, show_unified_list: show_unified_list, all_emails: all_emails, pagy_all: pagy_all %>
  57. <% end %>
  58. ERB
  59. else
  60. render :index
  61. end
  62. end
  63. end
  64. end
  65. # GET /signals/:id
  66. #
  67. # Show email detail with extracted signals - responds to Turbo Frame for split-pane
  68. def show
  69. @application = @email.interview_application
  70. @thread_emails = @email.thread_emails.includes(:email_sender)
  71. respond_to do |format|
  72. format.html do
  73. # Full page render for direct access or mobile
  74. render :show
  75. end
  76. format.turbo_stream do
  77. # Turbo Frame update for split-pane
  78. render turbo_stream: turbo_stream.update(
  79. "email_detail",
  80. partial: "signals/detail_panel",
  81. locals: { email: @email, thread_emails: @thread_emails, application: @application }
  82. )
  83. end
  84. end
  85. end
  86. # GET /signals/application_emails
  87. #
  88. # Turbo Frame endpoint used to progressively load matched emails (latest per thread)
  89. # for a single interview application group in the Signals list.
  90. #
  91. # Params:
  92. # - interview_application_id (required)
  93. # - limit (optional, defaults to 5; increases in batches of 5)
  94. # - plus any existing list filters (relevance, type, status, company_id, q)
  95. def application_emails
  96. application = Current.user.interview_applications.find(params[:interview_application_id])
  97. limit = params[:limit].to_i
  98. limit = 5 if limit <= 0
  99. limit = [ limit, 50 ].min
  100. emails = current_user_emails
  101. .includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
  102. .order(email_date: :desc)
  103. @current_relevance = params[:relevance] || "relevant"
  104. emails = filter_by_relevance(emails)
  105. emails = filter_by_type(emails)
  106. emails = filter_by_status(emails)
  107. emails = filter_by_company(emails)
  108. emails = search_emails(emails)
  109. emails = emails
  110. .matched
  111. .where(interview_application_id: application.id)
  112. # Match the list behavior: show only the latest email per thread.
  113. unique_threads = {}
  114. emails.each do |email|
  115. thread_key = email.thread_id || email.id
  116. if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
  117. unique_threads[thread_key] = email
  118. end
  119. end
  120. thread_emails = unique_threads.values
  121. .sort_by { |e| e.email_date || e.created_at }
  122. .reverse
  123. frame_id = "signals_application_#{application.id}_emails"
  124. render inline: <<~ERB, locals: { application: application, emails: thread_emails, limit: limit, frame_id: frame_id, selected_email_id: params[:selected_email_id].presence&.to_i }
  125. <%= turbo_frame_tag frame_id do %>
  126. <%= render "signals/application_emails", application: application, emails: emails, limit: limit, frame_id: frame_id, selected_email_id: selected_email_id %>
  127. <% end %>
  128. ERB
  129. end
  130. # PATCH /signals/:id/match_application
  131. #
  132. # Match email to an interview application
  133. def match_application
  134. application = Current.user.interview_applications.find_by(id: params[:application_id])
  135. if application && @email.match_to_application!(application)
  136. # Also match other emails in the same thread
  137. match_thread_emails(application) if @email.thread_id.present?
  138. # Reprocess this email now that it is matched
  139. Signals::EmailStateOrchestrator.new(@email).call
  140. respond_to do |format|
  141. format.html { redirect_to signals_path, notice: "Signal matched to #{application.company.name}." }
  142. format.turbo_stream do
  143. flash.now[:notice] = "Signal matched to #{application.company.name}."
  144. @thread_emails = @email.thread_emails.includes(:email_sender)
  145. reload_email_list_data
  146. render turbo_stream: [
  147. turbo_stream.update("email_detail",
  148. partial: "signals/detail_panel",
  149. locals: { email: @email, thread_emails: @thread_emails, application: application }
  150. ),
  151. turbo_stream.update("email_list",
  152. partial: "signals/email_list",
  153. locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @email.id }
  154. ),
  155. turbo_stream.update("flash",
  156. partial: "shared/flash"
  157. ),
  158. turbo_stream.update("email_stats",
  159. html: email_stats_html
  160. )
  161. ]
  162. end
  163. format.json { render json: { success: true, application_id: application.id } }
  164. end
  165. else
  166. respond_to do |format|
  167. format.html { redirect_to signals_path, alert: "Could not match signal to application." }
  168. format.turbo_stream do
  169. flash.now[:alert] = "Could not match signal to application."
  170. render turbo_stream: [
  171. turbo_stream.update("email_detail",
  172. html: "<div class='p-4 text-red-600'>Could not match signal</div>"
  173. ),
  174. turbo_stream.update("flash",
  175. partial: "shared/flash"
  176. )
  177. ]
  178. end
  179. format.json { render json: { success: false }, status: :unprocessable_entity }
  180. end
  181. end
  182. end
  183. # PATCH /signals/:id/ignore
  184. #
  185. # Mark email as not interview-related
  186. def ignore
  187. if @email.ignore!
  188. respond_to do |format|
  189. format.html { redirect_to signals_path, notice: "Signal dismissed." }
  190. format.turbo_stream do
  191. state = reload_email_list_data_from_referer
  192. render turbo_stream: [
  193. turbo_stream.update("email_detail",
  194. partial: "signals/empty_state"
  195. ),
  196. turbo_stream.update("email_list",
  197. partial: "signals/email_list",
  198. locals: {
  199. grouped_emails: state[:grouped_emails],
  200. unmatched_emails: state[:unmatched_emails],
  201. pagy_unmatched: state[:pagy_unmatched],
  202. selected_email_id: nil,
  203. show_unified_list: state[:show_unified_list],
  204. all_emails: state[:all_emails],
  205. pagy_all: state[:pagy_all]
  206. }
  207. ),
  208. turbo_stream.update("email_stats",
  209. html: email_stats_html
  210. )
  211. ]
  212. end
  213. format.json { render json: { success: true } }
  214. end
  215. else
  216. respond_to do |format|
  217. format.html { redirect_to signals_path, alert: "Could not dismiss signal." }
  218. format.turbo_stream do
  219. @thread_emails = @email.thread_emails.includes(:email_sender)
  220. render turbo_stream: turbo_stream.update(
  221. "email_detail",
  222. partial: "signals/detail_panel",
  223. locals: { email: @email, thread_emails: @thread_emails, application: @email.interview_application }
  224. )
  225. end
  226. format.json { render json: { success: false }, status: :unprocessable_entity }
  227. end
  228. end
  229. end
  230. # POST /signals/:id/execute_action
  231. #
  232. # Execute a signal action (start_application, schedule_interview, etc.)
  233. def execute_action
  234. action_type = params[:action_type]
  235. executor = Signals::ActionExecutor.new(@email, Current.user, action_type, params)
  236. result = executor.execute
  237. respond_to do |format|
  238. if result[:success]
  239. if result[:redirect_url]
  240. # External redirect (scheduling link, careers page, etc.)
  241. format.html { redirect_to result[:redirect_url], allow_other_host: result[:external] }
  242. format.turbo_stream do
  243. render turbo_stream: turbo_stream.action(:redirect, result[:redirect_url])
  244. end
  245. format.json { render json: result }
  246. elsif result[:redirect_path]
  247. # Internal redirect (new application page) - flash will show on target page
  248. flash[:notice] = result[:message]
  249. format.html { redirect_to result[:redirect_path], status: :see_other }
  250. format.turbo_stream { redirect_to result[:redirect_path], status: :see_other }
  251. format.json { render json: result }
  252. else
  253. # Action completed, refresh the view
  254. format.html { redirect_to signals_path, notice: result[:message] }
  255. format.turbo_stream do
  256. @thread_emails = @email.reload.thread_emails.includes(:email_sender)
  257. reload_email_list_data
  258. render turbo_stream: [
  259. turbo_stream.update("email_detail",
  260. partial: "signals/detail_panel",
  261. locals: { email: @email, thread_emails: @thread_emails, application: @email.interview_application }
  262. ),
  263. turbo_stream.update("email_list",
  264. partial: "signals/email_list",
  265. locals: { grouped_emails: @grouped_emails, unmatched_emails: @unmatched_emails, pagy_unmatched: @pagy_unmatched, selected_email_id: @email.id }
  266. ),
  267. turbo_stream.update("flash",
  268. partial: "shared/flash",
  269. locals: { notice: result[:message] }
  270. )
  271. ]
  272. end
  273. format.json { render json: result }
  274. end
  275. else
  276. format.html { redirect_to signal_path(@email), alert: result[:error] }
  277. format.turbo_stream do
  278. render turbo_stream: turbo_stream.update("flash",
  279. partial: "shared/flash",
  280. locals: { alert: result[:error] }
  281. )
  282. end
  283. format.json { render json: result, status: :unprocessable_entity }
  284. end
  285. end
  286. end
  287. private
  288. # Sets the email for member actions
  289. #
  290. # @return [SyncedEmail]
  291. def set_synced_email
  292. @email = current_user_emails.find(params[:id])
  293. rescue ActiveRecord::RecordNotFound
  294. respond_to do |format|
  295. format.html { redirect_to signals_path, alert: "Signal not found." }
  296. format.turbo_stream do
  297. render turbo_stream: turbo_stream.update(
  298. "email_detail",
  299. partial: "signals/empty_state"
  300. )
  301. end
  302. end
  303. end
  304. # Returns the current user's synced emails
  305. #
  306. # @return [ActiveRecord::Relation]
  307. def current_user_emails
  308. Current.user.synced_emails
  309. end
  310. # Filters emails by relevance (all, relevant, interviews, opportunities)
  311. #
  312. # @param emails [ActiveRecord::Relation]
  313. # @return [ActiveRecord::Relation]
  314. def filter_by_relevance(emails)
  315. case @current_relevance
  316. when "all"
  317. emails.visible # Excludes ignored and auto_ignored
  318. when "interviews"
  319. emails.interview_related.visible
  320. when "opportunities"
  321. emails.potential_opportunities.visible
  322. else # "relevant" (default)
  323. emails.relevant
  324. end
  325. end
  326. # Calculates counts for relevance filter tabs
  327. #
  328. # @return [Hash] Counts by relevance type
  329. def calculate_relevance_counts
  330. base = current_user_emails
  331. {
  332. all: base.visible.count,
  333. relevant: base.relevant.count,
  334. interviews: base.interview_related.visible.count,
  335. opportunities: base.potential_opportunities.visible.count
  336. }
  337. end
  338. # Filters emails by type
  339. #
  340. # @param emails [ActiveRecord::Relation]
  341. # @return [ActiveRecord::Relation]
  342. def filter_by_type(emails)
  343. return emails unless params[:type].present?
  344. emails.by_type(params[:type])
  345. end
  346. # Filters emails by status (matched/unmatched/all)
  347. #
  348. # @param emails [ActiveRecord::Relation]
  349. # @return [ActiveRecord::Relation]
  350. def filter_by_status(emails)
  351. case params[:status]
  352. when "matched"
  353. emails.matched
  354. when "unmatched"
  355. emails.unmatched
  356. when "pending"
  357. emails.pending
  358. when "ignored"
  359. emails.ignored
  360. else
  361. emails
  362. end
  363. end
  364. # Filters emails by company
  365. #
  366. # @param emails [ActiveRecord::Relation]
  367. # @return [ActiveRecord::Relation]
  368. def filter_by_company(emails)
  369. return emails unless params[:company_id].present?
  370. company = Company.find_by(id: params[:company_id])
  371. return emails unless company
  372. application_ids = Current.user.interview_applications
  373. .where(company: company)
  374. .pluck(:id)
  375. emails.where(interview_application_id: application_ids)
  376. end
  377. # Searches emails by query
  378. #
  379. # @param emails [ActiveRecord::Relation]
  380. # @return [ActiveRecord::Relation]
  381. def search_emails(emails)
  382. return emails unless params[:q].present?
  383. query = "%#{params[:q]}%"
  384. emails.where(
  385. "subject ILIKE :q OR from_email ILIKE :q OR from_name ILIKE :q OR snippet ILIKE :q",
  386. q: query
  387. )
  388. end
  389. # Groups emails by their associated application
  390. # Returns the latest email from each thread grouped by application
  391. #
  392. # @param emails [ActiveRecord::Relation]
  393. # @return [Hash]
  394. def group_emails_by_application(emails)
  395. # Get unique threads, keeping only the latest email from each thread
  396. unique_threads = {}
  397. emails.matched.each do |email|
  398. thread_key = email.thread_id || email.id
  399. if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
  400. unique_threads[thread_key] = email
  401. end
  402. end
  403. # Group by application
  404. unique_threads.values
  405. .group_by(&:interview_application)
  406. .transform_values { |app_emails| app_emails.sort_by { |e| e.email_date || e.created_at }.reverse }
  407. .sort_by { |app, _| app&.company&.name || "" }
  408. .to_h
  409. end
  410. # Groups emails by thread, keeping only the latest email from each thread
  411. #
  412. # @param emails [ActiveRecord::Relation]
  413. # @return [Array<SyncedEmail>]
  414. def group_emails_by_thread(emails)
  415. unique_threads = {}
  416. emails.each do |email|
  417. thread_key = email.thread_id.presence || "single_#{email.id}"
  418. if unique_threads[thread_key].nil? || (email.email_date && email.email_date > unique_threads[thread_key].email_date)
  419. unique_threads[thread_key] = email
  420. end
  421. end
  422. unique_threads.values.sort_by { |e| e.email_date || e.created_at }.reverse
  423. end
  424. # Matches all emails in the same thread to an application
  425. #
  426. # @param application [InterviewApplication]
  427. # @return [void]
  428. def match_thread_emails(application)
  429. current_user_emails
  430. .where(thread_id: @email.thread_id)
  431. .where.not(id: @email.id)
  432. .update_all(interview_application_id: application.id, status: :processed)
  433. end
  434. # Reloads the email list data for Turbo Stream updates
  435. #
  436. # @return [void]
  437. def reload_email_list_data
  438. emails = current_user_emails
  439. .includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
  440. .order(email_date: :desc)
  441. @grouped_emails = group_emails_by_application(emails)
  442. unmatched_by_thread = group_emails_by_thread(emails.unmatched)
  443. @pagy_unmatched, @unmatched_emails = pagy_array(unmatched_by_thread, limit: 15, page_param: :unmatched_page)
  444. end
  445. # Reloads the email list data using the Signals page query params.
  446. #
  447. # Turbo Stream actions like `ignore` are invoked from within the detail frame
  448. # (`/signals/:id`), so the request params typically DO NOT include the current
  449. # list filters (relevance/status/search/etc.). We therefore parse the referer
  450. # (the Signals page URL) and rebuild the list state consistently.
  451. #
  452. # @return [Hash]
  453. def reload_email_list_data_from_referer
  454. list_params = list_params_from_referer
  455. current_relevance = list_params[:relevance].presence || "relevant"
  456. emails = current_user_emails
  457. .includes(:interview_application, :email_sender, interview_application: [ :company, :job_role ])
  458. .order(email_date: :desc)
  459. emails = filter_emails_for_list(emails, list_params, current_relevance: current_relevance)
  460. show_unified_list = current_relevance == "all"
  461. if show_unified_list
  462. all_by_thread = group_emails_by_thread(emails)
  463. pagy_all, all_emails = pagy_array(
  464. all_by_thread,
  465. limit: 20,
  466. page: list_params[:page].presence,
  467. page_param: :page
  468. )
  469. {
  470. show_unified_list: true,
  471. grouped_emails: {},
  472. unmatched_emails: [],
  473. pagy_unmatched: nil,
  474. all_emails: all_emails,
  475. pagy_all: pagy_all
  476. }
  477. else
  478. grouped_emails = group_emails_by_application(emails)
  479. unmatched_by_thread = group_emails_by_thread(emails.unmatched)
  480. pagy_unmatched, unmatched_emails = pagy_array(
  481. unmatched_by_thread,
  482. limit: 15,
  483. page: list_params[:unmatched_page].presence,
  484. page_param: :unmatched_page
  485. )
  486. {
  487. show_unified_list: false,
  488. grouped_emails: grouped_emails,
  489. unmatched_emails: unmatched_emails,
  490. pagy_unmatched: pagy_unmatched,
  491. all_emails: [],
  492. pagy_all: nil
  493. }
  494. end
  495. end
  496. # Returns HTML for the email stats footer
  497. #
  498. # @return [String]
  499. def email_stats_html
  500. needs_review = Current.user.synced_emails.needs_review.count
  501. matched = Current.user.synced_emails.matched.count
  502. "<span>#{needs_review} signals need attention</span><span>#{matched} matched</span>"
  503. end
  504. # Extracts list params from the referer URL (best-effort).
  505. #
  506. # @return [ActionController::Parameters]
  507. def list_params_from_referer
  508. return ActionController::Parameters.new({}) if request.referer.blank?
  509. uri = URI.parse(request.referer)
  510. parsed = Rack::Utils.parse_nested_query(uri.query.to_s)
  511. ActionController::Parameters.new(parsed)
  512. rescue URI::InvalidURIError
  513. ActionController::Parameters.new({})
  514. end
  515. # Applies the same filters used by `index`, but based on explicit params.
  516. #
  517. # @param emails [ActiveRecord::Relation]
  518. # @param list_params [ActionController::Parameters]
  519. # @param current_relevance [String]
  520. # @return [ActiveRecord::Relation]
  521. def filter_emails_for_list(emails, list_params, current_relevance:)
  522. filtered = case current_relevance
  523. when "all"
  524. emails.visible
  525. when "interviews"
  526. emails.interview_related.visible
  527. when "opportunities"
  528. emails.potential_opportunities.visible
  529. else
  530. emails.relevant
  531. end
  532. if list_params[:type].present?
  533. filtered = filtered.by_type(list_params[:type])
  534. end
  535. case list_params[:status]
  536. when "matched"
  537. filtered = filtered.matched
  538. when "unmatched"
  539. filtered = filtered.unmatched
  540. when "pending"
  541. filtered = filtered.pending
  542. when "ignored"
  543. filtered = filtered.ignored
  544. end
  545. if list_params[:company_id].present?
  546. company = Company.find_by(id: list_params[:company_id])
  547. if company
  548. application_ids = Current.user.interview_applications
  549. .where(company: company)
  550. .pluck(:id)
  551. filtered = filtered.where(interview_application_id: application_ids)
  552. end
  553. end
  554. if list_params[:q].present?
  555. query = "%#{list_params[:q]}%"
  556. filtered = filtered.where(
  557. "subject ILIKE :q OR from_email ILIKE :q OR from_name ILIKE :q OR snippet ILIKE :q",
  558. q: query
  559. )
  560. end
  561. filtered
  562. end
  563. end

app/controllers/skill_tags_controller.rb

0.0% lines covered

100.0% branches covered

41 relevant lines. 0 lines covered and 41 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for skill tag autocomplete + JSON create.
  3. # Used by the shared autocomplete component.
  4. class SkillTagsController < ApplicationController
  5. # GET /skill_tags
  6. def index
  7. @skill_tags = SkillTag.enabled.alphabetical
  8. if params[:q].present?
  9. @skill_tags = @skill_tags.where("name ILIKE ?", "%#{params[:q]}%")
  10. end
  11. @skill_tags = @skill_tags.limit(50)
  12. respond_to do |format|
  13. format.html
  14. format.json { render json: @skill_tags }
  15. end
  16. end
  17. # GET /skill_tags/autocomplete
  18. def autocomplete
  19. query = params[:q].to_s.strip
  20. @skill_tags = if query.present?
  21. SkillTag.enabled.where("name ILIKE ?", "%#{query}%")
  22. .alphabetical
  23. .limit(10)
  24. else
  25. SkillTag.enabled.alphabetical.limit(10)
  26. end
  27. render json: @skill_tags.map { |t| { id: t.id, name: t.name, category: t.category_name } }
  28. end
  29. # POST /skill_tags
  30. def create
  31. return head :not_acceptable unless request.format.json?
  32. name = (params[:name] || params.dig(:skill_tag, :name))&.strip
  33. return render json: { errors: [ "Name is required" ] }, status: :unprocessable_entity if name.blank?
  34. # Find by case-insensitive name
  35. @skill_tag = SkillTag.where("LOWER(name) = ?", name.downcase).first
  36. if @skill_tag.nil?
  37. @skill_tag = SkillTag.new(name: name)
  38. if @skill_tag.save
  39. render json: { id: @skill_tag.id, name: @skill_tag.name }, status: :created
  40. else
  41. render json: { errors: @skill_tag.errors.full_messages }, status: :unprocessable_entity
  42. end
  43. else
  44. @skill_tag.update!(disabled_at: nil) if @skill_tag.disabled?
  45. render json: { id: @skill_tag.id, name: @skill_tag.name }, status: :ok
  46. end
  47. end
  48. end

app/controllers/skills_controller.rb

0.0% lines covered

100.0% branches covered

133 relevant lines. 0 lines covered and 133 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for the Skills Dashboard
  3. #
  4. # Provides a comprehensive view of the user's skill profile aggregated
  5. # across all resumes with visualizations and insights.
  6. class SkillsController < ApplicationController
  7. # GET /skills
  8. #
  9. # Main skills dashboard with aggregated skill profile
  10. def index
  11. @user_skills = Current.user.user_skills
  12. .includes(:skill_tag)
  13. .by_level_desc
  14. @skills_by_category = @user_skills.group_by(&:category)
  15. @top_skills = UserSkill.top_skills(Current.user, limit: 10)
  16. @development_areas = UserSkill.development_areas(Current.user, limit: 5)
  17. @skill_stats = calculate_skill_stats
  18. @category_stats = calculate_category_stats
  19. @resume_coverage = calculate_resume_coverage
  20. @skills_by_experience = calculate_skills_by_experience
  21. @merged_strengths = merged_strengths_for(Current.user)
  22. @resume_domains = aggregated_label_counts(Current.user.user_resumes.analyzed.pluck(:domains).flatten)
  23. end
  24. # GET /skills/:id
  25. #
  26. # Skill detail view showing proficiency + evidence of use in work history.
  27. def show
  28. @skill_tag = SkillTag.find(params[:id])
  29. @user_skill = Current.user.user_skills.includes(:skill_tag).find_by(skill_tag_id: @skill_tag.id)
  30. @experience_skill_rows = UserWorkExperienceSkill
  31. .includes(:skill_tag, user_work_experience: [ :company, :job_role ])
  32. .joins(:user_work_experience)
  33. .where(user_work_experiences: { user_id: Current.user.id }, skill_tag_id: @skill_tag.id)
  34. .order(Arel.sql("COALESCE(user_work_experiences.end_date, user_work_experiences.start_date) DESC NULLS LAST"), created_at: :desc)
  35. @resume_sources = UserResume
  36. .joins(resume_work_experiences: :resume_work_experience_skills)
  37. .where(user_id: Current.user.id, resume_work_experience_skills: { skill_tag_id: @skill_tag.id })
  38. .distinct
  39. .order(created_at: :desc)
  40. end
  41. private
  42. # Calculates overall skill statistics
  43. #
  44. # @return [Hash] Skill stats
  45. def calculate_skill_stats
  46. skills = Current.user.user_skills
  47. {
  48. total: skills.count,
  49. strong: skills.strong_skills.count,
  50. moderate: skills.moderate_skills.count,
  51. developing: skills.developing_skills.count,
  52. average_level: skills.average(:aggregated_level)&.round(1) || 0,
  53. most_demonstrated: skills.most_demonstrated.first
  54. }
  55. end
  56. # Calculates stats by category
  57. #
  58. # @return [Array<Hash>] Category stats sorted by count
  59. def calculate_category_stats
  60. Current.user.user_skills
  61. .group(:category)
  62. .select("category, COUNT(*) as count, AVG(aggregated_level) as avg_level")
  63. .order("count DESC")
  64. .map do |row|
  65. {
  66. category: row.category || "Other",
  67. count: row.count,
  68. avg_level: row.avg_level&.round(1) || 0
  69. }
  70. end
  71. end
  72. # Calculates resume coverage for skills
  73. #
  74. # @return [Hash] Resume coverage data
  75. def calculate_resume_coverage
  76. resumes = Current.user.user_resumes.analyzed
  77. total_resumes = resumes.count
  78. {
  79. total_resumes: total_resumes,
  80. resumes_with_skills: resumes.joins(:resume_skills).distinct.count,
  81. avg_skills_per_resume: total_resumes > 0 ? (ResumeSkill.joins(:user_resume).where(user_resumes: { user_id: Current.user.id }).count.to_f / total_resumes).round(1) : 0
  82. }
  83. end
  84. # Calculates experience-based usage for skills (skills used in distinct work experiences).
  85. #
  86. # @return [Array<Hash>]
  87. def calculate_skills_by_experience
  88. rows = UserWorkExperienceSkill
  89. .joins(user_work_experience: :user)
  90. .where(user_work_experiences: { user_id: Current.user.id })
  91. .group(:skill_tag_id)
  92. .select(
  93. :skill_tag_id,
  94. Arel.sql("COUNT(DISTINCT user_work_experience_id) AS experience_count"),
  95. Arel.sql("MAX(last_used_on) AS last_used_on")
  96. )
  97. .order(Arel.sql("experience_count DESC"), Arel.sql("last_used_on DESC NULLS LAST"))
  98. .limit(12)
  99. skill_tags = SkillTag.where(id: rows.map(&:skill_tag_id)).index_by(&:id)
  100. rows.map do |row|
  101. tag = skill_tags[row.skill_tag_id]
  102. {
  103. skill_tag_id: row.skill_tag_id,
  104. name: tag&.name || "Unknown",
  105. experience_count: row.try(:experience_count).to_i,
  106. last_used_on: row.try(:last_used_on)
  107. }
  108. end
  109. end
  110. def aggregated_label_counts(labels)
  111. Labels::DedupeService
  112. .new(labels, similarity_threshold: 0.82, overlap_threshold: 0.75)
  113. .grouped_counts
  114. end
  115. def merged_strengths_for(user)
  116. resume_counts = aggregated_label_counts(user.user_resumes.analyzed.pluck(:strengths).flatten)
  117. feedback_strengths = ProfileInsightsService.new(user).generate_insights[:strengths] || []
  118. feedback_counts = {}
  119. feedback_strengths.each do |row|
  120. name = row[:name] || row["name"]
  121. count = row[:count] || row["count"] || 0
  122. key = normalize_label_key(name)
  123. next if key.blank?
  124. feedback_counts[key] ||= { label: name.to_s.strip, count: 0 }
  125. feedback_counts[key][:count] += count.to_i
  126. end
  127. keys = (resume_counts.keys + feedback_counts.keys).uniq
  128. merged = keys.map do |key|
  129. resume = resume_counts[key]
  130. feedback = feedback_counts[key]
  131. label = resume&.dig(:label).presence || feedback&.dig(:label).presence || key
  132. resume_count = resume&.dig(:count).to_i
  133. feedback_count = feedback&.dig(:count).to_i
  134. sources = []
  135. sources << "resume" if resume_count.positive?
  136. sources << "feedback" if feedback_count.positive?
  137. {
  138. key: key,
  139. label: label,
  140. total_count: resume_count + feedback_count,
  141. resume_count: resume_count,
  142. feedback_count: feedback_count,
  143. sources: sources
  144. }
  145. end
  146. merged.sort_by { |h| -h[:total_count].to_i }
  147. end
  148. def normalize_label_key(label)
  149. # Kept for backward compatibility (used by merged_strengths_for feedback keys).
  150. ActiveSupport::Inflector.transliterate(label.to_s)
  151. .downcase
  152. .tr("&", "and")
  153. .gsub(/[^a-z0-9\s]/, " ")
  154. .gsub(/\s+/, " ")
  155. .strip
  156. end
  157. end

app/controllers/user_resumes_controller.rb

0.0% lines covered

100.0% branches covered

208 relevant lines. 0 lines covered and 208 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Controller for managing user resumes and skill profiles
  3. #
  4. # Provides CRUD operations for resumes and displays the aggregated skill profile
  5. class UserResumesController < ApplicationController
  6. before_action :set_user_resume, only: [ :show, :edit, :update, :destroy, :reanalyze ]
  7. # GET /resumes
  8. #
  9. # Main resumes view with CV list and aggregated skill profile
  10. def index
  11. @user_resumes = current_user_resumes.recent_first.includes(:target_job_roles, :target_companies)
  12. @user_skills = Current.user.user_skills.includes(:skill_tag).by_level_desc
  13. @skills_by_category = @user_skills.group_by(&:category)
  14. @job_roles = JobRole.order(:title)
  15. @companies = Company.order(:name)
  16. @merged_strengths = merged_strengths_for(Current.user)
  17. @resume_domains = aggregated_label_counts(Current.user.user_resumes.analyzed.pluck(:domains).flatten)
  18. end
  19. # GET /resumes/:id
  20. #
  21. # Show resume details with extracted skills for review
  22. def show
  23. base_skills = @user_resume.resume_skills
  24. .includes(:skill_tag)
  25. .order(Arel.sql("COALESCE(user_level, model_level) DESC"))
  26. @pagy, @resume_skills = pagy(base_skills, limit: 10)
  27. @skills_by_category = @resume_skills.group_by(&:category)
  28. @total_skills_count = base_skills.count
  29. @work_experiences = @user_resume.resume_work_experiences.includes(:company, :job_role, resume_work_experience_skills: :skill_tag).reverse_chronological
  30. respond_to do |format|
  31. format.html
  32. format.turbo_stream
  33. format.json do
  34. render json: {
  35. id: @user_resume.id,
  36. analysis_status: @user_resume.analysis_status,
  37. skills_count: @total_skills_count
  38. }
  39. end
  40. end
  41. end
  42. # GET /resumes/new
  43. #
  44. # Upload form for new resume
  45. def new
  46. @user_resume = Current.user.user_resumes.build
  47. @job_roles = JobRole.order(:title)
  48. @companies = Company.order(:name)
  49. end
  50. # POST /resumes
  51. #
  52. # Create a new resume and enqueue analysis
  53. def create
  54. @user_resume = Current.user.user_resumes.build(user_resume_params)
  55. if @user_resume.save
  56. maybe_unlock_insight_trial_after_cv_upload
  57. # Always redirect to show page to see processing status
  58. redirect_to user_resume_path(@user_resume), notice: "Resume uploaded! Analysis in progress..."
  59. else
  60. @job_roles = JobRole.order(:title)
  61. @companies = Company.order(:name)
  62. render :new, status: :unprocessable_entity
  63. end
  64. end
  65. # GET /resumes/:id/edit
  66. #
  67. # Edit resume metadata (name, purpose, target role/company)
  68. def edit
  69. @job_roles = JobRole.order(:title)
  70. @companies = Company.order(:name)
  71. end
  72. # PATCH/PUT /resumes/:id
  73. #
  74. # Update resume metadata
  75. def update
  76. respond_to do |format|
  77. if @user_resume.update(user_resume_params)
  78. format.html { redirect_to user_resume_path(@user_resume), notice: "Resume updated." }
  79. format.turbo_stream do
  80. render turbo_stream: [
  81. turbo_stream.replace("resume_card_#{@user_resume.id}", partial: "user_resumes/resume_card", locals: { user_resume: @user_resume }),
  82. turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "Resume updated." } })
  83. ]
  84. end
  85. else
  86. format.html do
  87. @job_roles = JobRole.order(:title)
  88. @companies = Company.order(:name)
  89. render :edit, status: :unprocessable_entity
  90. end
  91. format.turbo_stream do
  92. render turbo_stream: turbo_stream.update(
  93. "flash",
  94. partial: "shared/flash",
  95. locals: { flash: { alert: @user_resume.errors.full_messages.join(", ") } }
  96. )
  97. end
  98. end
  99. end
  100. end
  101. # DELETE /resumes/:id
  102. #
  103. # Delete resume and trigger skill re-aggregation
  104. def destroy
  105. @user_resume.destroy!
  106. # Re-aggregate skills for the user
  107. Resumes::SkillAggregationService.new(Current.user).aggregate_all
  108. respond_to do |format|
  109. format.html { redirect_to user_resumes_path, notice: "Resume deleted." }
  110. format.turbo_stream do
  111. @user_skills = Current.user.user_skills.includes(:skill_tag).by_level_desc
  112. @merged_strengths = merged_strengths_for(Current.user)
  113. @resume_domains = aggregated_label_counts(Current.user.user_resumes.analyzed.pluck(:domains).flatten)
  114. render turbo_stream: [
  115. turbo_stream.remove("resume_card_#{params[:id]}"),
  116. turbo_stream.update("skill_profile", partial: "user_resumes/skill_profile", locals: { user_skills: @user_skills, merged_strengths: @merged_strengths, domains: @resume_domains }),
  117. turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "Resume deleted." } })
  118. ]
  119. end
  120. end
  121. end
  122. # POST /resumes/:id/reanalyze
  123. #
  124. # Re-run AI analysis on existing resume
  125. def reanalyze
  126. if @user_resume.analysis_status_processing?
  127. respond_to do |format|
  128. format.html { redirect_to user_resume_path(@user_resume), alert: "Analysis already in progress." }
  129. format.turbo_stream do
  130. render turbo_stream: turbo_stream.update(
  131. "flash",
  132. partial: "shared/flash",
  133. locals: { flash: { alert: "Analysis already in progress." } }
  134. )
  135. end
  136. end
  137. return
  138. end
  139. # Reset status and re-enqueue
  140. @user_resume.update!(analysis_status: :pending)
  141. AnalyzeResumeJob.perform_later(@user_resume)
  142. respond_to do |format|
  143. format.html { redirect_to user_resume_path(@user_resume), notice: "Re-analysis started..." }
  144. format.turbo_stream do
  145. render turbo_stream: [
  146. turbo_stream.replace("resume_status_#{@user_resume.id}", partial: "user_resumes/analysis_status", locals: { user_resume: @user_resume }),
  147. turbo_stream.update("flash", partial: "shared/flash", locals: { flash: { notice: "Re-analysis started..." } })
  148. ]
  149. end
  150. end
  151. end
  152. private
  153. # Sets the resume for member actions
  154. #
  155. # @return [UserResume]
  156. def set_user_resume
  157. @user_resume = current_user_resumes.find(params[:id])
  158. rescue ActiveRecord::RecordNotFound
  159. respond_to do |format|
  160. format.html { redirect_to user_resumes_path, alert: "Resume not found." }
  161. format.turbo_stream do
  162. render turbo_stream: turbo_stream.update(
  163. "flash",
  164. partial: "shared/flash",
  165. locals: { flash: { alert: "Resume not found." } }
  166. )
  167. end
  168. end
  169. end
  170. # Returns the current user's resumes
  171. #
  172. # @return [ActiveRecord::Relation]
  173. def current_user_resumes
  174. Current.user.user_resumes
  175. end
  176. # Strong parameters for resume creation/update
  177. #
  178. # @return [ActionController::Parameters]
  179. def user_resume_params
  180. params.require(:user_resume).permit(
  181. :name,
  182. :file,
  183. :purpose,
  184. target_job_role_ids: [],
  185. target_company_ids: []
  186. )
  187. end
  188. # Aggregates a list of labels into a normalized hash with counts.
  189. #
  190. # @param labels [Array<String>]
  191. # @return [Hash{String => Hash}] e.g. { "system design" => { label: "System Design", count: 2 } }
  192. def aggregated_label_counts(labels)
  193. Labels::DedupeService
  194. .new(labels, similarity_threshold: 0.82, overlap_threshold: 0.75)
  195. .grouped_counts
  196. end
  197. def merged_strengths_for(user)
  198. resume_counts = aggregated_label_counts(user.user_resumes.analyzed.pluck(:strengths).flatten)
  199. feedback_strengths = ProfileInsightsService.new(user).generate_insights[:strengths] || []
  200. feedback_counts = {}
  201. feedback_strengths.each do |row|
  202. name = row[:name] || row["name"]
  203. count = row[:count] || row["count"] || 0
  204. key = normalize_label_key(name)
  205. next if key.blank?
  206. feedback_counts[key] ||= { label: name.to_s.strip, count: 0 }
  207. feedback_counts[key][:count] += count.to_i
  208. end
  209. keys = (resume_counts.keys + feedback_counts.keys).uniq
  210. merged = keys.map do |key|
  211. resume = resume_counts[key]
  212. feedback = feedback_counts[key]
  213. label = resume&.dig(:label).presence || feedback&.dig(:label).presence || key
  214. resume_count = resume&.dig(:count).to_i
  215. feedback_count = feedback&.dig(:count).to_i
  216. sources = []
  217. sources << "resume" if resume_count.positive?
  218. sources << "feedback" if feedback_count.positive?
  219. {
  220. key: key,
  221. label: label,
  222. total_count: resume_count + feedback_count,
  223. resume_count: resume_count,
  224. feedback_count: feedback_count,
  225. sources: sources
  226. }
  227. end
  228. merged.sort_by { |h| -h[:total_count].to_i }
  229. end
  230. def normalize_label_key(label)
  231. # Kept for backward compatibility (used by merged_strengths_for feedback keys).
  232. ActiveSupport::Inflector.transliterate(label.to_s)
  233. .downcase
  234. .tr("&", "and")
  235. .gsub(/[^a-z0-9\s]/, " ")
  236. .gsub(/\s+/, " ")
  237. .strip
  238. end
  239. # Unlocks the insight-triggered trial when the user uploads a CV and already has exactly one feedback entry.
  240. # This supports the "CV uploaded + first feedback entry" trigger regardless of which happens first.
  241. #
  242. # @return [void]
  243. def maybe_unlock_insight_trial_after_cv_upload
  244. user = Current.user
  245. return if user.nil?
  246. feedback_count = InterviewFeedback
  247. .joins(interview_round: { interview_application: :user })
  248. .where(users: { id: user.id })
  249. .count
  250. return unless feedback_count == 1
  251. Billing::TrialUnlockService.new(
  252. user: user,
  253. trigger: :cv_upload_with_first_feedback_present,
  254. metadata: { feedback_count: feedback_count, user_resume_id: @user_resume.id }
  255. ).run
  256. end
  257. end

app/controllers/webhooks/lemon_squeezy_controller.rb

0.0% lines covered

100.0% branches covered

51 relevant lines. 0 lines covered and 51 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Webhooks
  3. # Receives LemonSqueezy webhooks.
  4. #
  5. # Stores events for idempotency and async processing, then returns 200 quickly.
  6. class LemonSqueezyController < ApplicationController
  7. allow_unauthenticated_access
  8. skip_before_action :verify_authenticity_token
  9. # POST /webhooks/lemon_squeezy
  10. def create
  11. raw_body = request.raw_post.to_s
  12. signature = signature_header
  13. unless valid_signature?(raw_body, signature)
  14. Rails.logger.warn("[billing] Invalid LemonSqueezy webhook signature")
  15. head :unauthorized
  16. return
  17. end
  18. payload = JSON.parse(raw_body) rescue {}
  19. idempotency_key = request.headers["X-Event-Id"].presence || Digest::SHA256.hexdigest(raw_body)
  20. event_type = payload.dig("meta", "event_name") || payload["event_name"] || payload["type"]
  21. event = Billing::WebhookEvent.find_or_create_by!(provider: "lemonsqueezy", idempotency_key: idempotency_key) do |we|
  22. we.event_type = event_type
  23. we.payload = payload
  24. we.received_at = Time.current
  25. end
  26. Rails.logger.info("[billing] lemonsqueezy webhook received event_type=#{event_type} key=#{idempotency_key} status=#{event.status}")
  27. Billing::ProcessWebhookEventJob.perform_later(event) if event.processed_at.blank? && event.status == "pending"
  28. head :ok
  29. end
  30. private
  31. def signature_header
  32. request.headers["X-Signature"].presence ||
  33. request.headers["X-LemonSqueezy-Signature"].presence ||
  34. request.headers["X-LemonSqueezy-Signature".downcase].presence
  35. end
  36. def webhook_secret
  37. Rails.application.credentials.dig(:lemonsqueezy, :webhook_secret) || ENV["LEMONSQUEEZY_WEBHOOK_SECRET"]
  38. end
  39. def valid_signature?(raw_body, signature)
  40. secret = webhook_secret.to_s
  41. return false if secret.blank?
  42. return false if signature.blank?
  43. expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
  44. ActiveSupport::SecurityUtils.secure_compare(expected, signature.to_s)
  45. rescue => e
  46. ExceptionNotifier.notify(
  47. e,
  48. context: "payment",
  49. severity: "warning",
  50. tags: { provider: "lemonsqueezy", operation: "webhook_signature" },
  51. error: "signature_verification_failed"
  52. )
  53. false
  54. end
  55. end
  56. end

app/domains/assistant.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Assistant domain root namespace.
  3. #
  4. # All assistant-related code (chat, tools, policies, execution audit) should live under `Assistant::*`
  5. # and be placed in `app/domains/assistant/**`.
  6. 1 module Assistant
  7. end

app/domains/assistant/contracts/provider_result_contracts.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "dry/schema"
  3. module Assistant
  4. module Contracts
  5. # Runtime contracts for provider adapter outputs.
  6. #
  7. # These validate the adapter boundary so malformed provider responses become explicit errors
  8. # (instead of leaking as nils/odd hashes into the rest of the assistant pipeline).
  9. module ProviderResultContracts
  10. Openai = Dry::Schema.Params do
  11. required(:raw_response).value(:any)
  12. required(:content).value(:string)
  13. required(:tool_calls).array(:hash)
  14. required(:response_id).filled(:string)
  15. optional(:input_tokens).maybe(:integer)
  16. optional(:output_tokens).maybe(:integer)
  17. end
  18. Anthropic = Dry::Schema.Params do
  19. required(:raw_response).value(:any)
  20. required(:content).value(:string)
  21. required(:tool_calls).array(:hash)
  22. required(:content_blocks).array(:hash)
  23. required(:message_id).filled(:string)
  24. optional(:input_tokens).maybe(:integer)
  25. optional(:output_tokens).maybe(:integer)
  26. end
  27. end
  28. end
  29. end

app/domains/assistant/contracts/tool_call_contract.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "dry/schema"
  3. module Assistant
  4. module Contracts
  5. # Runtime contract for a normalized tool call produced by an LLM provider.
  6. #
  7. # Expected input (symbol or string keys):
  8. # - tool_key: String
  9. # - args: Hash
  10. # - provider_name: String
  11. # - provider_tool_call_id: String (OpenAI call_id / Anthropic tool_use_id)
  12. class ToolCallContract
  13. Schema = Dry::Schema.Params do
  14. required(:tool_key).filled(:string)
  15. required(:args).hash
  16. required(:provider_name).filled(:string)
  17. required(:provider_tool_call_id).filled(:string)
  18. end
  19. # @param tool_call [Hash]
  20. # @return [Dry::Schema::Result]
  21. def self.call(tool_call)
  22. Schema.call(tool_call)
  23. end
  24. end
  25. end
  26. end

app/domains/assistant/contracts/tool_result_contract.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "dry/schema"
  3. module Assistant
  4. module Contracts
  5. # Runtime contract for a tool result that will be sent back to an LLM provider.
  6. class ToolResultContract
  7. Schema = Dry::Schema.Params do
  8. required(:provider_tool_call_id).filled(:string)
  9. required(:tool_key).filled(:string)
  10. required(:success).filled(:bool)
  11. optional(:data).value(:any)
  12. optional(:error).maybe(:string)
  13. end
  14. # @param tool_result [Hash]
  15. # @return [Dry::Schema::Result]
  16. def self.call(tool_result)
  17. Schema.call(tool_result)
  18. end
  19. end
  20. end
  21. end

app/domains/assistant/models/chat_message.rb

0.0% lines covered

100.0% branches covered

14 relevant lines. 0 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. # A message within a chat thread.
  4. class ChatMessage < ApplicationRecord
  5. self.table_name = "assistant_messages"
  6. include Assistant::HasUuid
  7. ROLES = %w[user assistant tool].freeze
  8. belongs_to :thread,
  9. class_name: "Assistant::ChatThread",
  10. foreign_key: :thread_id,
  11. inverse_of: :messages
  12. validates :role, presence: true, inclusion: { in: ROLES }
  13. validates :content, presence: true
  14. scope :chronological, -> { order(created_at: :asc) }
  15. end
  16. end

app/domains/assistant/models/chat_thread.rb

66.67% lines covered

0.0% branches covered

18 relevant lines. 12 lines covered and 6 lines missed.
6 total branches, 0 branches covered and 6 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Assistant
  3. # A chat thread (conversation) for a single user.
  4. 1 class ChatThread < ApplicationRecord
  5. 1 self.table_name = "assistant_threads"
  6. 1 include Assistant::HasUuid
  7. 1 belongs_to :user
  8. 1 has_many :messages,
  9. class_name: "Assistant::ChatMessage",
  10. foreign_key: :thread_id,
  11. dependent: :destroy,
  12. inverse_of: :thread
  13. 1 has_many :turns,
  14. class_name: "Assistant::Turn",
  15. foreign_key: :thread_id,
  16. dependent: :destroy,
  17. inverse_of: :thread
  18. 1 has_many :tool_executions,
  19. class_name: "Assistant::ToolExecution",
  20. foreign_key: :thread_id,
  21. dependent: :destroy,
  22. inverse_of: :thread
  23. 1 has_one :summary,
  24. class_name: "Assistant::Memory::ThreadSummary",
  25. foreign_key: :thread_id,
  26. dependent: :destroy,
  27. inverse_of: :thread
  28. 1 scope :recent_first, -> { order(last_activity_at: :desc, updated_at: :desc) }
  29. # Returns a display-friendly title for the thread.
  30. # Uses the explicit title if set, otherwise derives from first user message.
  31. #
  32. # @return [String] the display title
  33. 1 def display_title
  34. then: 0 else: 0 return title if title.present?
  35. first_user_message = messages.where(role: "user").order(:created_at).first
  36. then: 0 else: 0 then: 0 if first_user_message&.content.present?
  37. first_user_message.content.truncate(50)
  38. else: 0 else
  39. "New conversation"
  40. end
  41. end
  42. # Returns a short version of the display title for sidebar links.
  43. #
  44. # @return [String] truncated title
  45. 1 def short_title
  46. display_title.truncate(35)
  47. end
  48. end
  49. end

app/domains/assistant/models/has_uuid.rb

83.33% lines covered

100.0% branches covered

12 relevant lines. 10 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "securerandom"
  3. 1 module Assistant
  4. # Adds a stable UUID identifier intended for external references (URLs, logs, admin ops).
  5. #
  6. # This keeps the internal integer primary key for joins, while providing a safe identifier
  7. # that can be shared in logs or used for idempotency keys.
  8. 1 module HasUuid
  9. 1 extend ActiveSupport::Concern
  10. 1 included do
  11. 2 before_validation :ensure_uuid, on: :create
  12. 2 validates :uuid, presence: true, uniqueness: true
  13. end
  14. 1 def to_param
  15. uuid
  16. end
  17. 1 private
  18. 1 def ensure_uuid
  19. self.uuid ||= SecureRandom.uuid
  20. end
  21. end
  22. end

app/domains/assistant/models/memory/memory_proposal.rb

0.0% lines covered

100.0% branches covered

14 relevant lines. 0 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Memory
  4. class MemoryProposal < ApplicationRecord
  5. self.table_name = "assistant_memory_proposals"
  6. include Assistant::HasUuid
  7. STATUSES = %w[pending accepted rejected expired].freeze
  8. belongs_to :thread, class_name: "Assistant::ChatThread"
  9. belongs_to :user
  10. belongs_to :llm_api_log, class_name: "Ai::LlmApiLog", optional: true
  11. belongs_to :confirmed_by, class_name: "User", optional: true
  12. validates :status, inclusion: { in: STATUSES }
  13. end
  14. end
  15. end

app/domains/assistant/models/memory/thread_summary.rb

0.0% lines covered

100.0% branches covered

11 relevant lines. 0 lines covered and 11 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Memory
  4. class ThreadSummary < ApplicationRecord
  5. self.table_name = "assistant_thread_summaries"
  6. include Assistant::HasUuid
  7. belongs_to :thread, class_name: "Assistant::ChatThread"
  8. belongs_to :last_summarized_message, class_name: "Assistant::ChatMessage", optional: true
  9. belongs_to :llm_api_log, class_name: "Ai::LlmApiLog", optional: true
  10. end
  11. end
  12. end

app/domains/assistant/models/memory/user_memory.rb

0.0% lines covered

100.0% branches covered

10 relevant lines. 0 lines covered and 10 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Memory
  4. class UserMemory < ApplicationRecord
  5. self.table_name = "assistant_user_memories"
  6. include Assistant::HasUuid
  7. belongs_to :user
  8. scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
  9. end
  10. end
  11. end

app/domains/assistant/models/ops/event.rb

0.0% lines covered

100.0% branches covered

11 relevant lines. 0 lines covered and 11 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Ops
  4. class Event < ApplicationRecord
  5. self.table_name = "assistant_events"
  6. include Assistant::HasUuid
  7. SEVERITIES = %w[debug info warn error].freeze
  8. belongs_to :thread, class_name: "Assistant::ChatThread"
  9. validates :severity, inclusion: { in: SEVERITIES }
  10. end
  11. end
  12. end

app/domains/assistant/models/tool.rb

0.0% lines covered

100.0% branches covered

13 relevant lines. 0 lines covered and 13 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. # Tool registry entry (admin-managed).
  4. class Tool < ApplicationRecord
  5. self.table_name = "assistant_tools"
  6. RISK_LEVELS = %w[read_only write_low write_high].freeze
  7. validates :tool_key, presence: true, uniqueness: true
  8. validates :name, presence: true
  9. validates :description, presence: true
  10. validates :risk_level, presence: true, inclusion: { in: RISK_LEVELS }
  11. validates :executor_class, presence: true
  12. scope :enabled, -> { where(enabled: true) }
  13. scope :by_key, -> { order(:tool_key) }
  14. end
  15. end

app/domains/assistant/models/tool_execution.rb

100.0% lines covered

100.0% branches covered

13 relevant lines. 13 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Assistant
  3. # A single tool execution record (audit + observability).
  4. 1 class ToolExecution < ApplicationRecord
  5. 1 self.table_name = "assistant_tool_executions"
  6. 1 include Assistant::HasUuid
  7. 1 STATUSES = %w[proposed queued running success error cancelled].freeze
  8. 1 PROVIDERS = %w[openai anthropic ollama].freeze
  9. 1 belongs_to :thread,
  10. class_name: "Assistant::ChatThread",
  11. foreign_key: :thread_id,
  12. inverse_of: :tool_executions
  13. 1 belongs_to :assistant_message,
  14. class_name: "Assistant::ChatMessage",
  15. inverse_of: false
  16. 1 belongs_to :approved_by,
  17. class_name: "User",
  18. optional: true
  19. 1 validates :tool_key, presence: true
  20. 1 validates :status, presence: true, inclusion: { in: STATUSES }
  21. 1 validates :trace_id, presence: true
  22. 1 validates :provider_name, inclusion: { in: PROVIDERS }, allow_blank: true
  23. end
  24. end

app/domains/assistant/models/turn.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. # A single assistant turn: user message -> assistant response (+ tools).
  4. class Turn < ApplicationRecord
  5. self.table_name = "assistant_turns"
  6. include Assistant::HasUuid
  7. STATUSES = %w[success error].freeze
  8. PROVIDERS = %w[openai anthropic ollama].freeze
  9. belongs_to :thread,
  10. class_name: "Assistant::ChatThread",
  11. foreign_key: :thread_id,
  12. inverse_of: :turns
  13. belongs_to :user_message,
  14. class_name: "Assistant::ChatMessage"
  15. belongs_to :assistant_message,
  16. class_name: "Assistant::ChatMessage"
  17. belongs_to :llm_api_log,
  18. class_name: "Ai::LlmApiLog"
  19. validates :trace_id, presence: true
  20. validates :status, presence: true, inclusion: { in: STATUSES }
  21. validates :provider_name, inclusion: { in: PROVIDERS }, allow_blank: true
  22. def to_param
  23. uuid
  24. end
  25. end
  26. end

app/domains/assistant/policies/tool_policy.rb

0.0% lines covered

100.0% branches covered

14 relevant lines. 0 lines covered and 14 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. # Determines which tools may be proposed for a given request.
  4. #
  5. # For now this is a thin wrapper around the enabled tool registry.
  6. # It will grow to support per-user disables, feature flags, etc.
  7. class ToolPolicy
  8. def initialize(user:, thread:, page_context: {})
  9. @user = user
  10. @thread = thread
  11. @page_context = page_context.to_h.symbolize_keys
  12. end
  13. def allowed_tools
  14. Assistant::Tool.enabled.by_key
  15. end
  16. private
  17. attr_reader :user, :thread, :page_context
  18. end
  19. end

app/domains/assistant/services/chat/components/llm_responder.rb

0.0% lines covered

100.0% branches covered

279 relevant lines. 0 lines covered and 279 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Chat
  4. module Components
  5. class LlmResponder
  6. def initialize(user:, trace_id:, question:, context:, allowed_tools:, thread: nil, media: nil)
  7. @user = user
  8. @trace_id = trace_id
  9. @question = question.to_s
  10. @context = context
  11. @allowed_tools = allowed_tools
  12. @thread = thread
  13. @media = Array(media).compact
  14. end
  15. def call
  16. system_prompt = build_system_prompt_with_context
  17. provider_chain = LlmProviders::ProviderConfigHelper.all_providers
  18. last_error = nil
  19. last_failure = nil
  20. provider_chain.each do |provider_name|
  21. res = attempt_provider(provider_name: provider_name, system_prompt: system_prompt)
  22. if res[:status] == "success"
  23. return res
  24. end
  25. last_error = res[:error].presence || last_error
  26. last_failure = res if res[:status] == "error"
  27. end
  28. build_fallback_error(
  29. provider_chain: provider_chain,
  30. system_prompt: system_prompt,
  31. last_error: last_error,
  32. last_failure: last_failure
  33. )
  34. end
  35. private
  36. attr_reader :user, :trace_id, :question, :context, :allowed_tools, :thread, :media
  37. # Returns the system prompt for the assistant.
  38. # Uses system_prompt column from DB if available, falls back to default.
  39. # This follows the same pattern as extraction prompts.
  40. #
  41. # @return [String] System prompt for the LLM
  42. def active_system_prompt
  43. Ai::AssistantSystemPrompt.active_prompt&.system_prompt.presence ||
  44. Ai::AssistantSystemPrompt.default_system_prompt
  45. end
  46. # Builds the system prompt with injected context
  47. #
  48. # The context includes:
  49. # - User info (name, account age)
  50. # - Career context (resume summary, work history, targets)
  51. # - Skills summary
  52. # - Pipeline status
  53. # - Current page context
  54. def build_system_prompt_with_context
  55. base_prompt = active_system_prompt
  56. context_section = <<~CONTEXT
  57. ---
  58. USER CONTEXT:
  59. #{format_context_for_prompt}
  60. ---
  61. CONTEXT
  62. "#{base_prompt}\n#{context_section}"
  63. end
  64. # Formats the context hash into a readable section for the system prompt
  65. def format_context_for_prompt
  66. sections = []
  67. # User info
  68. if context[:user].present?
  69. sections << "User: #{context[:user][:name]}"
  70. end
  71. # Career context (tiered - most important for job search assistance)
  72. if context[:career].present?
  73. career = context[:career]
  74. if career[:resume].present?
  75. resume = career[:resume]
  76. sections << "\nProfile Summary: #{resume[:profile_summary]}" if resume[:profile_summary].present?
  77. sections << "Strengths: #{resume[:strengths].join(', ')}" if resume[:strengths].present?
  78. sections << "Domains: #{resume[:domains].join(', ')}" if resume[:domains].present?
  79. # Full resume text only included when page_context has include_full_resume or resume_id
  80. if resume[:full_text].present?
  81. sections << "\n--- Full Resume ---\n#{resume[:full_text]}\n--- End Resume ---"
  82. end
  83. end
  84. if career[:work_history].present?
  85. work_lines = career[:work_history].map do |exp|
  86. status = exp[:current] ? "(Current)" : ""
  87. dates = [ exp[:start_date], exp[:end_date] ].compact.join(" - ")
  88. skills = exp[:skills].present? ? "Skills: #{exp[:skills].join(', ')}" : nil
  89. highlights = exp[:highlights].present? ? "Highlights: #{exp[:highlights].join('; ')}" : nil
  90. [ "• #{exp[:title]} at #{exp[:company]} #{status} #{dates}", skills, highlights ].compact.join("\n ")
  91. end
  92. sections << "\nWork History:\n#{work_lines.join("\n")}"
  93. end
  94. if career[:targets].present?
  95. targets = career[:targets]
  96. sections << "\nCareer Targets:" if targets.any?
  97. sections << " Target Roles: #{targets[:roles].join(', ')}" if targets[:roles].present?
  98. sections << " Target Companies: #{targets[:companies].join(', ')}" if targets[:companies].present?
  99. sections << " Target Domains: #{targets[:domains].join(', ')}" if targets[:domains].present?
  100. end
  101. end
  102. # Top skills
  103. if context[:skills].present? && context[:skills][:top_skills].present?
  104. skills = context[:skills][:top_skills].map { |s| s[:name] }.compact.first(10)
  105. sections << "\nTop Skills: #{skills.join(', ')}" if skills.any?
  106. end
  107. # Pipeline status
  108. if context[:pipeline].present?
  109. pipeline = context[:pipeline]
  110. sections << "\nPipeline: #{pipeline[:interview_applications_count]} applications"
  111. if pipeline[:recent_interview_applications].present?
  112. recent = pipeline[:recent_interview_applications].first(3).map do |app|
  113. base = "#{app[:job_role]} at #{app[:company]} (#{app[:status]})"
  114. # Include identifiers so the model can reliably call tools.
  115. # Many tools accept application_uuid/application_id, and without these the model may hallucinate.
  116. ids = []
  117. ids << "id=#{app[:id]}" if app[:id].present?
  118. ids << "uuid=#{app[:uuid]}" if app[:uuid].present?
  119. ids.any? ? "#{base} [#{ids.join(' ')}]" : base
  120. end
  121. sections << "Recent: #{recent.join('; ')}"
  122. end
  123. end
  124. # Page context
  125. if context[:page].present? && context[:page].any?
  126. page_info = context[:page].map { |k, v| "#{k}: #{v}" }.join(", ")
  127. sections << "\nCurrent Page: #{page_info}"
  128. end
  129. sections.join("\n")
  130. end
  131. def attempt_provider(provider_name:, system_prompt:)
  132. provider = provider_for(provider_name)
  133. return { status: "skipped", error: nil } unless provider&.available?
  134. router = Assistant::Providers::ProviderRouter.new(
  135. thread: thread,
  136. question: question,
  137. system_prompt: system_prompt,
  138. allowed_tools: allowed_tools,
  139. media: media
  140. )
  141. logger = Ai::ApiLoggerService.new(
  142. operation_type: :assistant_chat,
  143. loggable: user,
  144. provider: provider.provider_name,
  145. model: provider.model_name,
  146. llm_prompt: Ai::AssistantSystemPrompt.active_prompt
  147. )
  148. request_for_log = router.request_payload_for_log(provider: provider)
  149. result = logger.record(prompt: request_for_log, content_size: request_for_log.bytesize) do
  150. router.call(provider: provider)
  151. end
  152. if result[:error].present?
  153. return {
  154. status: "error",
  155. error: result[:error],
  156. error_type: result[:error_type],
  157. provider: provider.provider_name,
  158. model: provider.model_name,
  159. llm_api_log_id: result[:llm_api_log_id]
  160. }
  161. end
  162. build_success_response(provider: provider, result: result)
  163. end
  164. def build_success_response(provider:, result:)
  165. tool_calls = normalize_and_validate_tool_calls(result[:tool_calls], provider: provider.provider_name)
  166. answer, pending_tool_followup = finalize_answer(
  167. answer: result[:content].to_s,
  168. tool_calls: tool_calls
  169. )
  170. {
  171. answer: answer,
  172. tool_calls: tool_calls,
  173. llm_api_log: Ai::LlmApiLog.find(result[:llm_api_log_id]),
  174. latency_ms: result[:latency_ms],
  175. status: "success",
  176. metadata: {
  177. trace_id: trace_id,
  178. provider: provider.provider_name,
  179. model: provider.model_name,
  180. tool_calls: tool_calls,
  181. provider_state: extract_provider_state(provider: provider.provider_name, result: result),
  182. provider_content_blocks: (provider.provider_name.to_s.downcase == "anthropic" ? result[:content_blocks] : nil),
  183. pending_tool_followup: pending_tool_followup
  184. }.compact
  185. }
  186. end
  187. def normalize_and_validate_tool_calls(raw_tool_calls, provider:)
  188. calls = Array(raw_tool_calls).map { |tc| normalize_tool_call(tc, provider: provider) }.compact
  189. calls.select do |tc|
  190. contract = Assistant::Contracts::ToolCallContract.call(tc)
  191. next true if contract.success?
  192. Rails.logger.warn("[LlmResponder] Dropping invalid tool_call: errors=#{contract.errors.to_h.inspect} tool_call=#{tc.inspect}")
  193. false
  194. end
  195. end
  196. def finalize_answer(answer:, tool_calls:)
  197. pending_tool_followup = pending_tool_followup?(tool_calls: tool_calls)
  198. if pending_tool_followup
  199. return [ "Working on it — I’m fetching the latest info now.", true ]
  200. end
  201. if answer.strip.blank? && tool_calls.any?
  202. answer = "I have some proposed actions for you to review below."
  203. end
  204. answer = "I couldn't generate a response. Please try again." if answer.strip.blank?
  205. [ answer, false ]
  206. end
  207. def pending_tool_followup?(tool_calls:)
  208. return false if tool_calls.blank?
  209. tool_calls.any? do |tc|
  210. tool = allowed_tools.find { |t| t.tool_key == tc[:tool_key] }
  211. next false if tool.nil?
  212. (tool.requires_confirmation || tool.risk_level != "read_only") == false
  213. end
  214. end
  215. def build_fallback_error(provider_chain:, system_prompt:, last_error:, last_failure:)
  216. error_message = last_error || "All providers failed"
  217. # Prefer the real provider attempt log (it has provider/model/error_type/raw_response)
  218. # so the turn links to the useful failure details.
  219. if last_failure&.dig(:llm_api_log_id).present?
  220. log = Ai::LlmApiLog.find(last_failure[:llm_api_log_id])
  221. return {
  222. answer: "Sorry — I ran into an issue generating a response. Please try again.",
  223. tool_calls: [],
  224. llm_api_log: log,
  225. latency_ms: nil,
  226. status: "error",
  227. metadata: {
  228. trace_id: trace_id,
  229. provider: last_failure[:provider] || log.provider || "unknown",
  230. model: last_failure[:model] || log.model || "unknown",
  231. error: error_message,
  232. error_type: last_failure[:error_type] || log.error_type
  233. }.compact
  234. }
  235. end
  236. # No provider attempt log exists (e.g., all providers were unavailable). Record a synthetic log.
  237. fallback_provider = provider_for(provider_chain.first)
  238. logger = Ai::ApiLoggerService.new(
  239. operation_type: :assistant_chat,
  240. loggable: user,
  241. provider: (fallback_provider&.provider_name || "unknown"),
  242. model: (fallback_provider&.model_name || "unknown"),
  243. llm_prompt: Ai::AssistantSystemPrompt.active_prompt
  244. )
  245. request_for_log = build_request_for_log(provider_name: (fallback_provider&.provider_name || "unknown"), system_prompt: system_prompt)
  246. failed = logger.record(prompt: request_for_log, content_size: request_for_log.bytesize) do
  247. { content: nil, input_tokens: nil, output_tokens: nil, confidence: nil, error: error_message, error_type: "all_providers_failed" }
  248. end
  249. {
  250. answer: "Sorry — I ran into an issue generating a response. Please try again.",
  251. tool_calls: [],
  252. llm_api_log: Ai::LlmApiLog.find(failed[:llm_api_log_id]),
  253. latency_ms: failed[:latency_ms],
  254. status: "error",
  255. metadata: {
  256. trace_id: trace_id,
  257. provider: (fallback_provider&.provider_name || "unknown"),
  258. model: (fallback_provider&.model_name || "unknown"),
  259. error: error_message,
  260. error_type: "all_providers_failed"
  261. }.compact
  262. }
  263. end
  264. def normalize_tool_call(tc, provider:)
  265. h = tc.is_a?(Hash) ? tc : {}
  266. tool_key = h[:tool_key] || h["tool_key"] || h[:name] || h["name"]
  267. args = h[:args] || h["args"] || h[:input] || h["input"] || {}
  268. tool_call_id = h[:id] || h["id"] || h[:call_id] || h["call_id"]
  269. return nil if tool_key.blank?
  270. {
  271. tool_key: tool_key.to_s,
  272. args: args.is_a?(Hash) ? args : {},
  273. provider_name: provider.to_s,
  274. provider_tool_call_id: tool_call_id.to_s.presence
  275. }.compact
  276. end
  277. def extract_provider_state(provider:, result:)
  278. case provider.to_s.downcase
  279. when "openai"
  280. { response_id: result[:response_id] || result["response_id"] }.compact
  281. when "anthropic"
  282. { message_id: result[:message_id] || result["message_id"] }.compact
  283. else
  284. {}
  285. end
  286. end
  287. def build_request_for_log(provider_name:, system_prompt:)
  288. # Used only for synthetic fallback logging paths. ProviderRouter is used for real provider attempts.
  289. {
  290. provider: provider_name,
  291. system: system_prompt.to_s,
  292. question: question.to_s,
  293. tools_count: allowed_tools.size
  294. }.to_json
  295. end
  296. def provider_for(provider_name)
  297. case provider_name.to_s.downcase
  298. when "openai" then LlmProviders::OpenaiProvider.new
  299. when "anthropic" then LlmProviders::AnthropicProvider.new
  300. when "ollama" then LlmProviders::OllamaProvider.new
  301. else nil
  302. end
  303. end
  304. end
  305. end
  306. end
  307. end

app/domains/assistant/services/chat/components/prompt_builder.rb

0.0% lines covered

100.0% branches covered

52 relevant lines. 0 lines covered and 52 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Chat
  4. module Components
  5. class PromptBuilder
  6. MAX_HISTORY_MESSAGES = 20
  7. def initialize(context:, question:, allowed_tools:, conversation_history: [])
  8. @context = context
  9. @question = question.to_s
  10. @allowed_tools = allowed_tools
  11. @conversation_history = Array(conversation_history)
  12. end
  13. def build
  14. tool_list = allowed_tools.map do |t|
  15. {
  16. tool_key: t.tool_key,
  17. description: t.description,
  18. arg_schema: t.arg_schema,
  19. requires_confirmation: t.requires_confirmation,
  20. risk_level: t.risk_level
  21. }
  22. end
  23. sections = []
  24. sections << <<~SECTION
  25. CONTEXT (JSON):
  26. #{context.to_json}
  27. SECTION
  28. sections << <<~SECTION
  29. AVAILABLE_TOOLS (JSON):
  30. #{tool_list.to_json}
  31. SECTION
  32. if conversation_history.any?
  33. sections << <<~SECTION
  34. CONVERSATION_HISTORY:
  35. #{format_conversation_history}
  36. SECTION
  37. end
  38. sections << <<~SECTION
  39. USER_QUESTION:
  40. #{question}
  41. SECTION
  42. sections.join("\n")
  43. end
  44. private
  45. attr_reader :context, :question, :allowed_tools, :conversation_history
  46. def format_conversation_history
  47. # Take the most recent messages, respecting the limit
  48. recent_messages = conversation_history.last(MAX_HISTORY_MESSAGES)
  49. recent_messages.map do |msg|
  50. role = msg[:role] || msg["role"]
  51. content = msg[:content] || msg["content"]
  52. "[#{role.upcase}]: #{content}"
  53. end.join("\n\n")
  54. end
  55. end
  56. end
  57. end
  58. end

app/domains/assistant/services/chat/components/tool_followup_responder.rb

0.0% lines covered

100.0% branches covered

218 relevant lines. 0 lines covered and 218 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Chat
  4. module Components
  5. # Continues a tool-using turn by sending tool results back to the LLM using the
  6. # provider-native protocol, then (optionally) executing additional read-only
  7. # tools requested by the model up to a bounded iteration limit.
  8. class ToolFollowupResponder
  9. MAX_ITERATIONS = 3
  10. def initialize(user:, thread:, originating_assistant_message:)
  11. @user = user
  12. @thread = thread
  13. @originating_assistant_message = originating_assistant_message
  14. end
  15. # @return [Hash] { answer:, tool_executions: }
  16. def call
  17. turn = Assistant::Turn.find_by(thread: thread, assistant_message: originating_assistant_message)
  18. provider_name = (turn&.provider_name || originating_assistant_message.metadata["provider"] || originating_assistant_message.metadata[:provider]).to_s
  19. provider_state = turn&.provider_state || {}
  20. allowed_tools = Assistant::ToolPolicy.new(user: user, thread: thread, page_context: {}).allowed_tools
  21. tool_executions = Assistant::ToolExecution.where(thread: thread, assistant_message: originating_assistant_message).order(created_at: :asc)
  22. results = tool_executions.map { |te| tool_result_for(te) }.compact
  23. case provider_name.downcase
  24. when "openai"
  25. openai_followup(
  26. turn: turn,
  27. provider_state: provider_state,
  28. allowed_tools: allowed_tools,
  29. tool_results: results
  30. )
  31. when "anthropic"
  32. anthropic_followup(
  33. allowed_tools: allowed_tools,
  34. tool_results: results
  35. )
  36. else
  37. { answer: "Sorry — tool follow-up is not supported for provider: #{provider_name}.", tool_executions: [] }
  38. end
  39. end
  40. private
  41. attr_reader :user, :thread, :originating_assistant_message
  42. def openai_followup(turn:, provider_state:, allowed_tools:, tool_results:)
  43. previous_response_id = provider_state["response_id"] || provider_state[:response_id]
  44. if previous_response_id.blank?
  45. return { answer: "Sorry — I couldn't continue the tool-assisted response (missing provider state).", tool_executions: [] }
  46. end
  47. provider = LlmProviders::OpenaiProvider.new
  48. tools = Assistant::Tools::ToolSchemaAdapter.new(allowed_tools).for_openai
  49. tool_outputs = tool_results.map { |tr| { call_id: tr[:provider_tool_call_id], output: tr.to_json } }
  50. iterations = 0
  51. created = []
  52. loop do
  53. iterations += 1
  54. break if iterations > MAX_ITERATIONS
  55. logger = Ai::ApiLoggerService.new(
  56. operation_type: :assistant_tool_call,
  57. loggable: user,
  58. provider: provider.provider_name,
  59. model: provider.model_name,
  60. llm_prompt: Ai::AssistantSystemPrompt.active_prompt
  61. )
  62. result = logger.record(prompt: { previous_response_id: previous_response_id, tool_outputs: tool_outputs }.to_json) do
  63. provider.run(
  64. nil,
  65. # Provide a minimal message list so OpenAI input is always valid.
  66. messages: [ { role: "user", content: "" } ],
  67. tools: tools,
  68. previous_response_id: previous_response_id,
  69. tool_outputs: tool_outputs,
  70. temperature: 0.2,
  71. max_tokens: 1200
  72. )
  73. end
  74. break if result[:error].present?
  75. if result[:response_id].present?
  76. previous_response_id = result[:response_id]
  77. persist_openai_response_id!(turn: turn, response_id: previous_response_id)
  78. end
  79. tool_calls = Array(result[:tool_calls])
  80. if tool_calls.any? && iterations < MAX_ITERATIONS
  81. created += create_and_execute_followup_tools(tool_calls, provider_name: provider.provider_name)
  82. tool_outputs = created.last(tool_calls.length).map { |te|
  83. { call_id: te.provider_tool_call_id, output: tool_result_for(te).to_json }
  84. }
  85. next
  86. end
  87. answer = result[:content].to_s
  88. answer = "Done." if answer.strip.blank?
  89. return { answer: answer, tool_executions: created }
  90. end
  91. { answer: "Sorry — I couldn’t finish the tool follow-up.", tool_executions: created }
  92. end
  93. def persist_openai_response_id!(turn:, response_id:)
  94. return if turn.nil? || response_id.blank?
  95. turn.update!(provider_name: "openai", provider_state: (turn.provider_state || {}).merge("response_id" => response_id, "awaiting_tool_outputs" => false))
  96. originating_assistant_message.update!(
  97. metadata: originating_assistant_message.metadata.merge(
  98. "provider_state" => (originating_assistant_message.metadata["provider_state"] || {}).merge("response_id" => response_id),
  99. "awaiting_tool_outputs" => false
  100. )
  101. )
  102. rescue StandardError
  103. # best-effort only
  104. end
  105. def anthropic_followup(allowed_tools:, tool_results:)
  106. provider = LlmProviders::AnthropicProvider.new
  107. system_prompt = Ai::AssistantSystemPrompt.active_prompt&.system_prompt.presence ||
  108. Ai::AssistantSystemPrompt.default_system_prompt
  109. tools = Assistant::Tools::ToolSchemaAdapter.new(allowed_tools).for_anthropic
  110. messages = Assistant::Providers::Anthropic::MessageBuilder.new(
  111. thread: thread,
  112. question: "",
  113. system_prompt: system_prompt,
  114. allowed_tools: allowed_tools,
  115. media: []
  116. ).build_history_messages(
  117. exclude_tool_results_for_assistant_message_id: originating_assistant_message.id,
  118. include_pending_assistant_message_id: originating_assistant_message.id
  119. )
  120. created = []
  121. tool_result_blocks = tool_results.map do |tr|
  122. {
  123. type: "tool_result",
  124. tool_use_id: tr[:provider_tool_call_id],
  125. content: tr.to_json,
  126. is_error: tr[:success] == false
  127. }.compact
  128. end
  129. iterations = 0
  130. loop do
  131. iterations += 1
  132. break if iterations > MAX_ITERATIONS
  133. logger = Ai::ApiLoggerService.new(
  134. operation_type: :assistant_tool_call,
  135. loggable: user,
  136. provider: provider.provider_name,
  137. model: provider.model_name,
  138. llm_prompt: Ai::AssistantSystemPrompt.active_prompt
  139. )
  140. # IMPORTANT: for Anthropic, once we send tool_result blocks to the API, we must also
  141. # persist them into our in-memory `messages` history. Anthropic is stateless and enforces
  142. # the adjacency rule for *every* prior tool_use block in history: tool_use must be
  143. # immediately followed by a user message with tool_result.
  144. #
  145. # If we don't append tool_result messages into `messages`, then a subsequent iteration
  146. # (where the model requests more tools) will include a prior tool_use assistant message
  147. # without its immediately-following tool_result message, causing a 400.
  148. call_messages = messages + [ { role: "user", content: tool_result_blocks } ]
  149. result = logger.record(prompt: { messages_count: call_messages.length, tool_results_count: tool_result_blocks.length }.to_json) do
  150. provider.run(
  151. nil,
  152. messages: call_messages,
  153. tools: tools,
  154. system_message: system_prompt,
  155. temperature: 0.2,
  156. max_tokens: 1200
  157. )
  158. end
  159. break if result[:error].present?
  160. # Persist the tool_result user message into history (see comment above).
  161. messages = call_messages
  162. # Add the full assistant blocks back to history so tool_result blocks have context.
  163. if result[:content_blocks].present?
  164. messages << { role: "assistant", content: result[:content_blocks] }
  165. else
  166. messages << { role: "assistant", content: result[:content].to_s }
  167. end
  168. tool_calls = Array(result[:tool_calls])
  169. if tool_calls.any? && iterations < MAX_ITERATIONS
  170. created += create_and_execute_followup_tools(tool_calls, provider_name: provider.provider_name)
  171. tool_result_blocks = created.last(tool_calls.length).map { |te|
  172. tr = tool_result_for(te)
  173. {
  174. type: "tool_result",
  175. tool_use_id: te.provider_tool_call_id,
  176. content: tr.to_json,
  177. is_error: tr[:success] == false
  178. }.compact
  179. }
  180. next
  181. end
  182. answer = result[:content].to_s
  183. answer = "Done." if answer.strip.blank?
  184. return { answer: answer, tool_executions: created }
  185. end
  186. { answer: "Sorry — I couldn’t finish the tool follow-up.", tool_executions: created }
  187. end
  188. def create_and_execute_followup_tools(tool_calls, provider_name:)
  189. created = []
  190. Array(tool_calls).each do |tc|
  191. tool_key = tc[:tool_key] || tc["tool_key"] || tc[:name] || tc["name"]
  192. args = tc[:args] || tc["args"] || tc[:input] || tc["input"] || {}
  193. provider_tool_call_id = tc[:id] || tc["id"] || tc[:call_id] || tc["call_id"]
  194. tool = Assistant::Tool.find_by(tool_key: tool_key.to_s)
  195. next unless tool&.enabled?
  196. te = Assistant::ToolExecution.create!(
  197. thread: thread,
  198. assistant_message: originating_assistant_message,
  199. tool_key: tool.tool_key,
  200. args: args.is_a?(Hash) ? args : {},
  201. status: "proposed",
  202. trace_id: originating_assistant_message.metadata["trace_id"] || originating_assistant_message.metadata[:trace_id] || SecureRandom.uuid,
  203. requires_confirmation: tool.requires_confirmation || tool.risk_level != "read_only",
  204. idempotency_key: SecureRandom.uuid,
  205. provider_name: provider_name,
  206. provider_tool_call_id: provider_tool_call_id.to_s.presence
  207. )
  208. created << te
  209. next if te.requires_confirmation
  210. Assistant::Tools::Runner.new(user: user, tool_execution: te).call
  211. end
  212. created
  213. end
  214. def tool_result_for(tool_execution)
  215. return nil if tool_execution.provider_tool_call_id.blank?
  216. return nil unless tool_execution.status.in?(%w[success error])
  217. result = {
  218. provider_tool_call_id: tool_execution.provider_tool_call_id,
  219. tool_key: tool_execution.tool_key,
  220. success: tool_execution.status == "success",
  221. data: tool_execution.result,
  222. error: tool_execution.error
  223. }.compact
  224. contract = Assistant::Contracts::ToolResultContract.call(result)
  225. if contract.success?
  226. result
  227. else
  228. Rails.logger.warn("[ToolFollowupResponder] Invalid tool_result dropped: errors=#{contract.errors.to_h.inspect} tool_result=#{result.inspect}")
  229. nil
  230. end
  231. end
  232. end
  233. end
  234. end
  235. end

app/domains/assistant/services/chat/components/tool_proposal_recorder.rb

0.0% lines covered

100.0% branches covered

44 relevant lines. 0 lines covered and 44 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "securerandom"
  3. require "digest"
  4. module Assistant
  5. module Chat
  6. module Components
  7. class ToolProposalRecorder
  8. def initialize(trace_id:, assistant_message:, tool_calls:)
  9. @trace_id = trace_id
  10. @assistant_message = assistant_message
  11. @tool_calls = tool_calls || []
  12. end
  13. def call
  14. created = []
  15. tool_calls.each do |tc|
  16. tool = Assistant::Tool.find_by(tool_key: tc[:tool_key])
  17. next unless tool&.enabled?
  18. args = tc[:args] || {}
  19. dedupe_key = Digest::SHA256.hexdigest([ tool.tool_key, args ].to_json)
  20. existing = Assistant::ToolExecution.where(thread: assistant_message.thread, assistant_message: assistant_message, tool_key: tool.tool_key)
  21. .where("metadata ->> 'dedupe_key' = ?", dedupe_key)
  22. .exists?
  23. next if existing
  24. created << Assistant::ToolExecution.create!(
  25. thread: assistant_message.thread,
  26. assistant_message: assistant_message,
  27. tool_key: tool.tool_key,
  28. args: args,
  29. status: "proposed",
  30. trace_id: trace_id,
  31. requires_confirmation: tool.requires_confirmation || tool.risk_level != "read_only",
  32. idempotency_key: SecureRandom.uuid,
  33. provider_name: tc[:provider_name] || tc["provider_name"],
  34. provider_tool_call_id: tc[:provider_tool_call_id] || tc["provider_tool_call_id"],
  35. metadata: { dedupe_key: dedupe_key }
  36. )
  37. end
  38. created
  39. end
  40. private
  41. attr_reader :trace_id, :assistant_message, :tool_calls
  42. end
  43. end
  44. end
  45. end

app/domains/assistant/services/chat/orchestrator.rb

0.0% lines covered

100.0% branches covered

42 relevant lines. 0 lines covered and 42 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "securerandom"
  3. module Assistant
  4. module Chat
  5. # Orchestrates a single assistant turn:
  6. # - persists user message
  7. # - builds context snapshot
  8. # - calls LLM (with fallback providers)
  9. # - persists assistant message + turn record
  10. # - records proposed tool executions (not executed here)
  11. class Orchestrator
  12. # @param user [User]
  13. # @param thread [Assistant::ChatThread, nil]
  14. # @param message [String]
  15. # @param page_context [Hash]
  16. # @param client_request_uuid [String, nil]
  17. # @param media [Array<Hash>, nil] Optional media attachments
  18. def initialize(user:, thread: nil, message:, page_context: {}, client_request_uuid: nil, media: nil)
  19. @user = user
  20. @thread = thread
  21. @message = message.to_s
  22. @page_context = page_context.to_h
  23. @client_request_uuid = client_request_uuid.presence
  24. @media = Array(media).compact
  25. end
  26. def call
  27. raise ArgumentError, "message is blank" if message.strip.blank?
  28. ensure_thread!
  29. trace_id = SecureRandom.uuid
  30. # Store media metadata in message for replay/debugging
  31. msg_metadata = { trace_id: trace_id, page_context: page_context }
  32. msg_metadata[:has_media] = true if media.present?
  33. msg_metadata[:media_types] = media.map { |m| m[:media_type] }.compact if media.present?
  34. user_msg = thread.messages.create!(
  35. role: "user",
  36. content: message,
  37. metadata: msg_metadata
  38. )
  39. Assistant::Chat::TurnRunner.new(
  40. user: user,
  41. thread: thread,
  42. user_message: user_msg,
  43. trace_id: trace_id,
  44. client_request_uuid: client_request_uuid,
  45. page_context: page_context,
  46. media: media
  47. ).call
  48. end
  49. private
  50. attr_reader :user, :thread, :message, :page_context, :client_request_uuid, :media
  51. def ensure_thread!
  52. @thread ||= Assistant::ChatThread.create!(user: user, title: nil, last_activity_at: Time.current, status: "open")
  53. end
  54. # LLM/tool proposal logic extracted into Assistant::Chat::Components::*
  55. end
  56. end
  57. end

app/domains/assistant/services/chat/tool_result_message_persister.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Chat
  4. # Persists a tool execution outcome as a canonical tool-result chat message.
  5. #
  6. # This is used to build provider-native histories reliably (especially for Anthropic,
  7. # which requires tool_use blocks to be followed by tool_result blocks).
  8. class ToolResultMessagePersister
  9. # @param tool_execution [Assistant::ToolExecution]
  10. def initialize(tool_execution:)
  11. @tool_execution = tool_execution
  12. end
  13. # @return [Assistant::ChatMessage, nil]
  14. def call
  15. return nil if tool_execution.nil?
  16. return nil unless tool_execution.status.in?(%w[success error])
  17. msg = find_or_initialize_message
  18. msg.role = "tool"
  19. msg.content = build_content
  20. msg.metadata = build_metadata
  21. msg.save!
  22. msg
  23. rescue StandardError
  24. nil
  25. end
  26. private
  27. attr_reader :tool_execution
  28. def find_or_initialize_message
  29. Assistant::ChatMessage
  30. .where(thread: tool_execution.thread, role: "tool")
  31. .where("metadata ->> 'tool_execution_id' = ?", tool_execution.id.to_s)
  32. .first || tool_execution.thread.messages.build(role: "tool")
  33. end
  34. def build_content
  35. status = tool_execution.status == "success" ? "success" : "error"
  36. "Tool result (#{tool_execution.tool_key}): #{status}"
  37. end
  38. def build_metadata
  39. {
  40. tool_execution_id: tool_execution.id,
  41. trace_id: tool_execution.trace_id,
  42. provider_name: tool_execution.provider_name,
  43. provider_tool_call_id: tool_execution.provider_tool_call_id,
  44. tool_key: tool_execution.tool_key,
  45. success: tool_execution.status == "success",
  46. data: tool_execution.result,
  47. error: tool_execution.error,
  48. originating_assistant_message_id: tool_execution.assistant_message_id
  49. }.compact
  50. end
  51. end
  52. end
  53. end

app/domains/assistant/services/chat/turn_runner.rb

0.0% lines covered

100.0% branches covered

120 relevant lines. 0 lines covered and 120 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Chat
  4. # Runs a single assistant turn given an already-persisted user message.
  5. #
  6. # Owns the core workflow:
  7. # - build context snapshot + allowed tools
  8. # - call LLM (with provider fallback)
  9. # - persist assistant message + Assistant::Turn
  10. # - record proposed tool executions
  11. # - enqueue read-only tool executions and background jobs
  12. #
  13. # This centralizes logic so controllers/jobs do not duplicate the LLM/tool flow.
  14. class TurnRunner
  15. # @param user [User]
  16. # @param thread [Assistant::ChatThread]
  17. # @param user_message [Assistant::ChatMessage] must be role="user"
  18. # @param trace_id [String]
  19. # @param client_request_uuid [String, nil]
  20. # @param page_context [Hash]
  21. # @param media [Array<Hash>, nil] Optional media attachments (images, documents)
  22. def initialize(user:, thread:, user_message:, trace_id:, client_request_uuid: nil, page_context: {}, media: nil)
  23. @user = user
  24. @thread = thread
  25. @user_message = user_message
  26. @trace_id = trace_id.to_s
  27. @client_request_uuid = client_request_uuid.presence
  28. @page_context = page_context.to_h
  29. @media = Array(media).compact
  30. end
  31. # @return [Hash] { thread:, user_message:, assistant_message:, turn:, trace_id:, tool_calls:, tool_executions: }
  32. def call
  33. validate_inputs!
  34. existing = find_existing_turn
  35. return existing if existing
  36. result = run_transaction!
  37. enqueue_background_jobs(result)
  38. enqueue_auto_tools(result)
  39. result
  40. end
  41. private
  42. attr_reader :user, :thread, :user_message, :trace_id, :client_request_uuid, :page_context, :media
  43. def validate_inputs!
  44. raise ArgumentError, "thread is required" if thread.nil?
  45. raise ArgumentError, "user is required" if user.nil?
  46. raise ArgumentError, "user_message is required" if user_message.nil?
  47. raise ArgumentError, "trace_id is required" if trace_id.blank?
  48. end
  49. def find_existing_turn
  50. return nil if client_request_uuid.blank?
  51. existing = Assistant::Turn.where(thread: thread, client_request_uuid: client_request_uuid).first
  52. return nil unless existing
  53. {
  54. thread: thread,
  55. user_message: existing.user_message,
  56. assistant_message: existing.assistant_message,
  57. turn: existing,
  58. trace_id: existing.trace_id,
  59. tool_calls: Array(existing.assistant_message&.metadata&.dig("tool_calls") || existing.assistant_message&.metadata&.dig(:tool_calls)),
  60. tool_executions: Assistant::ToolExecution.where(thread: thread, assistant_message_id: existing.assistant_message_id).order(created_at: :asc)
  61. }
  62. end
  63. def run_transaction!
  64. ActiveRecord::Base.transaction do
  65. context = Assistant::Context::Builder.new(user: user, page_context: page_context).build
  66. allowed_tools = Assistant::ToolPolicy.new(user: user, thread: thread, page_context: page_context).allowed_tools
  67. llm_result = Assistant::Chat::Components::LlmResponder.new(
  68. user: user,
  69. trace_id: trace_id,
  70. question: user_message.content,
  71. context: context,
  72. allowed_tools: allowed_tools,
  73. thread: thread,
  74. media: media
  75. ).call
  76. assistant_message = persist_assistant_message!(llm_result)
  77. turn = persist_turn!(assistant_message: assistant_message, context: context, llm_result: llm_result)
  78. tool_executions = persist_tool_executions!(assistant_message: assistant_message, llm_result: llm_result)
  79. {
  80. thread: thread,
  81. user_message: user_message,
  82. assistant_message: assistant_message,
  83. turn: turn,
  84. trace_id: trace_id,
  85. tool_calls: llm_result[:tool_calls] || [],
  86. tool_executions: tool_executions
  87. }
  88. end
  89. end
  90. def persist_assistant_message!(llm_result)
  91. thread.messages.create!(
  92. role: "assistant",
  93. content: llm_result.fetch(:answer),
  94. metadata: llm_result.fetch(:metadata).merge(trace_id: trace_id)
  95. ).tap do
  96. thread.update!(last_activity_at: Time.current) if thread.last_activity_at.nil? || thread.last_activity_at < Time.current
  97. end
  98. end
  99. def persist_turn!(assistant_message:, context:, llm_result:)
  100. provider_name = llm_result.dig(:metadata, :provider).to_s
  101. provider_state = (llm_result.dig(:metadata, :provider_state) || {}).dup
  102. # For OpenAI, any emitted tool call puts the response into an "awaiting tool outputs" state.
  103. # Until tool outputs are sent back (follow-up), we must not continue the conversation using
  104. # previous_response_id, otherwise OpenAI will 400 ("No tool output found for function call ...").
  105. if provider_name == "openai" && Array(llm_result[:tool_calls]).any?
  106. provider_state["awaiting_tool_outputs"] = true
  107. end
  108. Assistant::Turn.create!(
  109. thread: thread,
  110. user_message: user_message,
  111. assistant_message: assistant_message,
  112. trace_id: trace_id,
  113. context_snapshot: context,
  114. llm_api_log: llm_result.fetch(:llm_api_log),
  115. latency_ms: llm_result[:latency_ms],
  116. status: llm_result[:status] || "success",
  117. client_request_uuid: client_request_uuid,
  118. provider_name: provider_name.presence,
  119. provider_state: provider_state
  120. )
  121. end
  122. def persist_tool_executions!(assistant_message:, llm_result:)
  123. Assistant::Chat::Components::ToolProposalRecorder.new(
  124. trace_id: trace_id,
  125. assistant_message: assistant_message,
  126. tool_calls: llm_result[:tool_calls] || []
  127. ).call
  128. end
  129. def enqueue_background_jobs(result)
  130. AssistantThreadSummarizerJob.perform_later(result[:thread].id)
  131. AssistantMemoryProposerJob.perform_later(user.id, result[:thread].id, result[:trace_id])
  132. end
  133. def enqueue_auto_tools(result)
  134. Array(result[:tool_executions]).each do |tool_execution|
  135. next if tool_execution.requires_confirmation
  136. tool_execution.update!(status: "queued") if tool_execution.status == "proposed"
  137. AssistantToolExecutionJob.perform_later(tool_execution.id)
  138. end
  139. end
  140. end
  141. end
  142. end

app/domains/assistant/services/context/builder.rb

0.0% lines covered

100.0% branches covered

178 relevant lines. 0 lines covered and 178 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Context
  4. # Builds a bounded context snapshot for the assistant.
  5. #
  6. # This should remain deterministic and small; it is persisted for observability.
  7. #
  8. # Context Strategy:
  9. # - Always include: user summary, top skills, work history summary, target roles/domains
  10. # - Include resume summary: by default (low token cost)
  11. # - Include full resume text: only when page_context[:include_full_resume] is true
  12. # (e.g., user is on resume page, or query explicitly needs raw resume content)
  13. class Builder
  14. # Maximum work experiences to include
  15. MAX_WORK_EXPERIENCES = 5
  16. # Maximum skills per work experience
  17. MAX_SKILLS_PER_EXPERIENCE = 5
  18. def initialize(user:, page_context: {})
  19. @user = user
  20. @page_context = page_context.to_h.symbolize_keys
  21. end
  22. def build
  23. {
  24. user: user_summary,
  25. career: career_summary,
  26. skills: skill_summary,
  27. pipeline: pipeline_summary,
  28. page: page_summary
  29. }
  30. end
  31. private
  32. attr_reader :user, :page_context
  33. def user_summary
  34. {
  35. id: user.id,
  36. name: user.display_name,
  37. email_verified: user.email_verified?,
  38. created_at: user.created_at&.iso8601
  39. }
  40. end
  41. # Career context: resume summaries, work history, targets
  42. # This provides rich context with minimal tokens (~300-800 tokens)
  43. def career_summary
  44. latest = latest_resume
  45. extraction = resume_extraction(latest)
  46. {
  47. resume: resume_summary(latest, extraction),
  48. work_history: work_history_summary,
  49. targets: targets_summary
  50. }.compact
  51. end
  52. def resume_summary(latest, extraction)
  53. return nil if latest.nil?
  54. summary = {
  55. profile_summary: latest.analysis_summary,
  56. strengths: Array(extraction["strengths"]).first(5),
  57. domains: Array(extraction["domains"]).first(5),
  58. resume_count: user.user_resumes.count,
  59. latest_analyzed_at: latest.analyzed_at&.iso8601
  60. }.compact
  61. # Include full resume text only when explicitly requested
  62. if include_full_resume?
  63. summary[:full_text] = latest.parsed_text.to_s.truncate(10_000)
  64. end
  65. summary
  66. end
  67. def work_history_summary
  68. experiences = user.user_work_experiences
  69. .reverse_chronological
  70. .includes(:skill_tags)
  71. .limit(MAX_WORK_EXPERIENCES)
  72. return nil if experiences.empty?
  73. experiences.map do |exp|
  74. {
  75. title: exp.display_role_title,
  76. company: exp.display_company_name,
  77. current: exp.current,
  78. start_date: exp.start_date&.strftime("%b %Y"),
  79. end_date: exp.current ? "Present" : exp.end_date&.strftime("%b %Y"),
  80. highlights: Array(exp.highlights).first(3),
  81. skills: exp.skill_tags.pluck(:name).first(MAX_SKILLS_PER_EXPERIENCE)
  82. }.compact
  83. end
  84. end
  85. def targets_summary
  86. target_roles = user.respond_to?(:target_job_roles) ? user.target_job_roles.pluck(:title).first(5) : []
  87. target_companies = user.respond_to?(:target_companies) ? user.target_companies.pluck(:name).first(5) : []
  88. target_domains = user.respond_to?(:target_domains) ? user.target_domains.pluck(:name).first(5) : []
  89. targets = {
  90. roles: target_roles.presence,
  91. companies: target_companies.presence,
  92. domains: target_domains.presence
  93. }.compact
  94. targets.presence
  95. end
  96. def skill_summary
  97. top = user.respond_to?(:top_skills) ? user.top_skills(limit: 10) : []
  98. {
  99. top_skills: Array(top).map do |us|
  100. {
  101. name: us.try(:skill_tag)&.try(:name),
  102. level: us.try(:level),
  103. evidence: us.try(:evidence).presence
  104. }.compact
  105. end.compact
  106. }
  107. end
  108. def pipeline_summary
  109. apps = user.interview_applications.order(updated_at: :desc).limit(10)
  110. {
  111. interview_applications_count: user.interview_applications.count,
  112. recent_interview_applications: apps.map do |a|
  113. {
  114. uuid: a.uuid,
  115. id: a.id,
  116. company: a.company&.name,
  117. job_role: a.job_role&.title,
  118. status: a.status,
  119. updated_at: a.updated_at&.iso8601
  120. }.compact
  121. end
  122. }
  123. end
  124. def page_summary
  125. summary = {
  126. job_listing_id: page_context[:job_listing_id],
  127. interview_application_id: page_context[:interview_application_id],
  128. interview_application_uuid: page_context[:interview_application_uuid],
  129. opportunity_id: page_context[:opportunity_id],
  130. resume_id: page_context[:resume_id]
  131. }.compact
  132. focused = focused_interview_application_summary
  133. summary[:focused_interview_application] = focused if focused.present?
  134. summary
  135. end
  136. # Helper methods
  137. def latest_resume
  138. @latest_resume ||= user.user_resumes
  139. .analyzed
  140. .recent_first
  141. .first
  142. end
  143. def resume_extraction(resume)
  144. return {} if resume.nil?
  145. data = resume.extracted_data
  146. data = JSON.parse(data) if data.is_a?(String)
  147. data&.dig("resume_extraction", "parsed") || {}
  148. rescue JSON::ParserError
  149. {}
  150. end
  151. # Include full resume text when:
  152. # 1. Explicitly requested via page_context
  153. # 2. User is viewing a resume page (resume_id present)
  154. def include_full_resume?
  155. page_context[:include_full_resume] == true ||
  156. page_context[:resume_id].present?
  157. end
  158. # @return [InterviewApplication, nil]
  159. def focused_interview_application
  160. uuid = page_context[:interview_application_uuid].to_s.presence
  161. id = page_context[:interview_application_id]
  162. if uuid.present?
  163. return user.interview_applications.includes(:company, :job_role, :interview_rounds).find_by(uuid: uuid)
  164. end
  165. if id.present?
  166. return user.interview_applications.includes(:company, :job_role, :interview_rounds).find_by(id: id)
  167. end
  168. nil
  169. end
  170. # @return [Hash, nil]
  171. def focused_interview_application_summary
  172. app = focused_interview_application
  173. return nil if app.nil?
  174. next_round = app.interview_rounds.upcoming.order(:scheduled_at).first
  175. {
  176. uuid: app.uuid,
  177. id: app.id,
  178. company: app.display_company&.name,
  179. job_role: app.display_job_role&.title,
  180. status: app.status,
  181. pipeline_stage: app.pipeline_stage,
  182. applied_at: app.applied_at&.iso8601,
  183. notes_preview: app.notes.to_s.truncate(500),
  184. next_interview: next_round ? {
  185. id: next_round.id,
  186. stage: next_round.stage,
  187. stage_name: next_round.stage_display_name,
  188. scheduled_at: next_round.scheduled_at,
  189. interviewer: next_round.interviewer_display
  190. }.compact : nil,
  191. needs_scheduling: app.needs_scheduling?,
  192. actionable_scheduling_link: app.actionable_scheduling_link
  193. }.compact
  194. rescue StandardError
  195. nil
  196. end
  197. end
  198. end
  199. end

app/domains/assistant/services/memory/memory_proposer.rb

0.0% lines covered

100.0% branches covered

89 relevant lines. 0 lines covered and 89 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Memory
  4. # Proposes long-term memory items for user confirmation.
  5. #
  6. # Always user-confirmed: this service only creates MemoryProposal records.
  7. class MemoryProposer
  8. def initialize(user:, thread:, trace_id:)
  9. @user = user
  10. @thread = thread
  11. @trace_id = trace_id
  12. end
  13. def propose!
  14. return nil if recent_pending_proposal?
  15. messages = thread.messages.order(created_at: :desc).limit(20).pluck(:role, :content).reverse
  16. prompt = build_prompt(messages)
  17. llm_log = run_llm(prompt)
  18. return nil if llm_log.nil?
  19. items = parse_items(llm_log.response_text)
  20. return nil if items.empty?
  21. Assistant::Memory::MemoryProposal.create!(
  22. thread: thread,
  23. user: user,
  24. trace_id: trace_id,
  25. proposed_items: items,
  26. status: "pending",
  27. llm_api_log: llm_log
  28. )
  29. end
  30. private
  31. attr_reader :user, :thread, :trace_id
  32. def recent_pending_proposal?
  33. Assistant::Memory::MemoryProposal.where(user: user, thread: thread, status: "pending").where("created_at > ?", 12.hours.ago).exists?
  34. end
  35. def build_prompt(messages)
  36. prompt_template = Ai::AssistantMemoryProposalPrompt.active_prompt
  37. template = prompt_template&.prompt_template || Ai::AssistantMemoryProposalPrompt.default_prompt_template
  38. template.gsub("{{messages}}", messages.map { |r, c| "#{r.upcase}: #{c}" }.join("\n"))
  39. end
  40. def run_llm(prompt)
  41. provider_chain = LlmProviders::ProviderConfigHelper.all_providers
  42. provider_chain.each do |provider_name|
  43. provider = provider_for(provider_name)
  44. next unless provider&.available?
  45. system_message = <<~SYS
  46. Return only valid JSON. Do not include markdown or extra commentary.
  47. SYS
  48. logger = Ai::ApiLoggerService.new(
  49. operation_type: :assistant_chat,
  50. loggable: user,
  51. provider: provider.provider_name,
  52. model: provider.model_name,
  53. llm_prompt: Ai::AssistantMemoryProposalPrompt.active_prompt
  54. )
  55. result = logger.record(prompt: prompt, content_size: prompt.bytesize) do
  56. provider.run(prompt, system_message: system_message, temperature: 0.1, max_tokens: 800)
  57. end
  58. next if result[:error].present?
  59. return Ai::LlmApiLog.find(result[:llm_api_log_id])
  60. end
  61. nil
  62. end
  63. def parse_items(text)
  64. json = text.to_s.strip
  65. match = json.match(/\{.*\}/m)
  66. json = match[0] if match
  67. data = JSON.parse(json)
  68. items = Array(data["items"])
  69. items.filter_map do |item|
  70. next unless item.is_a?(Hash)
  71. key = item["key"].to_s
  72. next if key.blank?
  73. {
  74. "key" => key,
  75. "value" => item["value"].is_a?(Hash) ? item["value"] : { "value" => item["value"] },
  76. "reason" => item["reason"].to_s,
  77. "confidence" => item["confidence"].to_f
  78. }
  79. end
  80. rescue JSON::ParserError
  81. []
  82. end
  83. def provider_for(provider_name)
  84. case provider_name.to_s.downcase
  85. when "openai" then LlmProviders::OpenaiProvider.new
  86. when "anthropic" then LlmProviders::AnthropicProvider.new
  87. when "ollama" then LlmProviders::OllamaProvider.new
  88. else nil
  89. end
  90. end
  91. end
  92. end
  93. end

app/domains/assistant/services/memory/thread_summarizer.rb

0.0% lines covered

100.0% branches covered

71 relevant lines. 0 lines covered and 71 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Memory
  4. # Produces/updates a rolling summary for a thread to bound prompt size.
  5. class ThreadSummarizer
  6. SUMMARY_EVERY_N_MESSAGES = 20
  7. def initialize(thread:)
  8. @thread = thread
  9. end
  10. def maybe_summarize!
  11. summary = thread.summary || thread.build_summary
  12. last_id = summary.last_summarized_message_id
  13. scope = thread.messages.order(:id)
  14. scope = scope.where("id > ?", last_id) if last_id.present?
  15. new_count = scope.count
  16. return nil if new_count < SUMMARY_EVERY_N_MESSAGES
  17. messages = scope.limit(60).pluck(:role, :content)
  18. prompt = build_prompt(existing_summary: summary.summary_text, messages: messages)
  19. llm_log = run_llm(prompt)
  20. return nil if llm_log.nil?
  21. new_summary_text = parse_summary(llm_log.response_text)
  22. summary.update!(
  23. summary_text: new_summary_text,
  24. summary_version: summary.summary_version.to_i + 1,
  25. last_summarized_message_id: scope.maximum(:id),
  26. llm_api_log: llm_log
  27. )
  28. summary
  29. end
  30. private
  31. attr_reader :thread
  32. def build_prompt(existing_summary:, messages:)
  33. prompt_template = Ai::AssistantThreadSummaryPrompt.active_prompt
  34. template = prompt_template&.prompt_template || Ai::AssistantThreadSummaryPrompt.default_prompt_template
  35. template
  36. .gsub("{{existing_summary}}", existing_summary.to_s)
  37. .gsub("{{messages}}", messages.map { |r, c| "#{r.upcase}: #{c}" }.join("\n"))
  38. end
  39. def run_llm(prompt)
  40. provider_chain = LlmProviders::ProviderConfigHelper.all_providers
  41. provider_chain.each do |provider_name|
  42. provider = provider_for(provider_name)
  43. next unless provider&.available?
  44. system_message = "Return only the updated summary text."
  45. logger = Ai::ApiLoggerService.new(
  46. operation_type: :assistant_chat,
  47. loggable: thread,
  48. provider: provider.provider_name,
  49. model: provider.model_name,
  50. llm_prompt: Ai::AssistantThreadSummaryPrompt.active_prompt
  51. )
  52. result = logger.record(prompt: prompt, content_size: prompt.bytesize) do
  53. provider.run(prompt, system_message: system_message, temperature: 0.1, max_tokens: 600)
  54. end
  55. next if result[:error].present?
  56. return Ai::LlmApiLog.find(result[:llm_api_log_id])
  57. end
  58. nil
  59. end
  60. def parse_summary(text)
  61. text.to_s.strip
  62. end
  63. def provider_for(provider_name)
  64. case provider_name.to_s.downcase
  65. when "openai" then LlmProviders::OpenaiProvider.new
  66. when "anthropic" then LlmProviders::AnthropicProvider.new
  67. when "ollama" then LlmProviders::OllamaProvider.new
  68. else nil
  69. end
  70. end
  71. end
  72. end
  73. end

app/domains/assistant/services/providers/anthropic/message_builder.rb

0.0% lines covered

100.0% branches covered

113 relevant lines. 0 lines covered and 113 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Providers
  4. module Anthropic
  5. # Builds Anthropic Messages API request options for assistant chat and follow-ups.
  6. #
  7. # Key rule: every assistant tool_use block must be immediately followed by a user message
  8. # with tool_result blocks for those tool_use ids.
  9. class MessageBuilder
  10. MAX_HISTORY_MESSAGES = 40
  11. def initialize(thread:, question:, system_prompt:, allowed_tools:, media: [])
  12. @thread = thread
  13. @question = question.to_s
  14. @system_prompt = system_prompt.to_s
  15. @allowed_tools = Array(allowed_tools)
  16. @media = Array(media).compact
  17. end
  18. # @return [Hash] options hash for LlmProviders::AnthropicProvider#run
  19. def build_chat_options
  20. tools = Assistant::Tools::ToolSchemaAdapter.new(allowed_tools).for_anthropic
  21. messages = build_history_messages + [ { role: "user", content: question } ]
  22. opts = {
  23. messages: messages,
  24. tools: tools,
  25. system_message: system_prompt,
  26. temperature: 0.2,
  27. max_tokens: 1200
  28. }
  29. opts[:media] = media if media.present?
  30. opts
  31. end
  32. # @return [Hash]
  33. def build_log_payload
  34. {
  35. provider: "anthropic",
  36. system: system_prompt.to_s,
  37. messages_count: build_history_messages.length + 1,
  38. tools_count: allowed_tools.size
  39. }
  40. end
  41. # Builds conversational history including persisted tool results.
  42. #
  43. # @param exclude_tool_results_for_assistant_message_id [Integer, nil]
  44. # @param include_pending_assistant_message_id [Integer, nil] include even if pending_tool_followup
  45. # @return [Array<Hash>]
  46. def build_history_messages(exclude_tool_results_for_assistant_message_id: nil, include_pending_assistant_message_id: nil)
  47. return [] if thread.nil?
  48. msgs = thread.messages.chronological.to_a.last(MAX_HISTORY_MESSAGES)
  49. out = []
  50. msgs.each do |m|
  51. next unless m.role.in?(%w[user assistant])
  52. is_included_pending = include_pending_assistant_message_id.to_i == m.id
  53. next if !is_included_pending && m.role == "assistant" && (m.metadata["pending_tool_followup"] == true || m.metadata[:pending_tool_followup] == true)
  54. next if m.role == "assistant" && (m.metadata["followup_for_assistant_message_id"].present? || m.metadata[:followup_for_assistant_message_id].present?)
  55. if m.role == "assistant" && m.metadata["provider"] == "anthropic" && m.metadata["provider_content_blocks"].is_a?(Array)
  56. blocks = Array(m.metadata["provider_content_blocks"])
  57. out << { role: "assistant", content: blocks }
  58. tool_use_ids = extract_tool_use_ids(blocks)
  59. next if tool_use_ids.empty?
  60. next if exclude_tool_results_for_assistant_message_id.to_i == m.id
  61. tool_result_blocks = build_tool_result_blocks_for_assistant_message(m, tool_use_ids)
  62. out << { role: "user", content: tool_result_blocks } if tool_result_blocks.any?
  63. else
  64. out << { role: m.role, content: m.content.to_s }
  65. end
  66. end
  67. out
  68. end
  69. private
  70. attr_reader :thread, :question, :system_prompt, :allowed_tools, :media
  71. def extract_tool_use_ids(blocks)
  72. Array(blocks).filter_map do |b|
  73. next unless (b["type"] || b[:type]).to_s == "tool_use"
  74. (b["id"] || b[:id]).to_s.presence
  75. end
  76. end
  77. def build_tool_result_blocks_for_assistant_message(assistant_message, tool_use_ids)
  78. tool_messages = Assistant::ChatMessage
  79. .where(thread: assistant_message.thread, role: "tool")
  80. .where("metadata ->> 'originating_assistant_message_id' = ?", assistant_message.id.to_s)
  81. .where("metadata ->> 'provider_tool_call_id' IN (?)", tool_use_ids)
  82. .order(created_at: :asc)
  83. .to_a
  84. by_call_id = tool_messages.index_by { |m| m.metadata["provider_tool_call_id"] || m.metadata[:provider_tool_call_id] }
  85. tool_use_ids.map do |id|
  86. tm = by_call_id[id]
  87. payload =
  88. if tm
  89. meta = tm.metadata || {}
  90. {
  91. provider_tool_call_id: meta["provider_tool_call_id"] || meta[:provider_tool_call_id],
  92. tool_key: meta["tool_key"] || meta[:tool_key] || "unknown",
  93. success: meta["success"] == true || meta[:success] == true,
  94. data: meta["data"] || meta[:data],
  95. error: meta["error"] || meta[:error]
  96. }.compact
  97. else
  98. fallback_tool_execution_payload(assistant_message, id)
  99. end
  100. {
  101. type: "tool_result",
  102. tool_use_id: id,
  103. content: payload.to_json,
  104. is_error: payload[:success] == false
  105. }.compact
  106. end
  107. end
  108. def fallback_tool_execution_payload(assistant_message, tool_use_id)
  109. te = Assistant::ToolExecution.where(thread: assistant_message.thread, assistant_message: assistant_message, provider_tool_call_id: tool_use_id).order(created_at: :desc).first
  110. if te && te.status.in?(%w[success error])
  111. {
  112. provider_tool_call_id: te.provider_tool_call_id,
  113. tool_key: te.tool_key,
  114. success: te.status == "success",
  115. data: te.result,
  116. error: te.error
  117. }.compact
  118. else
  119. { provider_tool_call_id: tool_use_id, tool_key: "unknown", success: false, error: "Tool result unavailable" }
  120. end
  121. end
  122. end
  123. end
  124. end
  125. end

app/domains/assistant/services/providers/anthropic/parser.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Providers
  4. module Anthropic
  5. # Normalizes Anthropic provider results into a stable internal shape.
  6. class Parser
  7. # @param provider_result [Hash]
  8. # @return [Hash]
  9. def self.normalize(provider_result)
  10. h = provider_result.is_a?(Hash) ? provider_result : {}
  11. {
  12. content: h[:content] || h["content"],
  13. tool_calls: h[:tool_calls] || h["tool_calls"] || [],
  14. content_blocks: h[:content_blocks] || h["content_blocks"],
  15. message_id: h[:message_id] || h["message_id"]
  16. }.compact
  17. end
  18. end
  19. end
  20. end
  21. end

app/domains/assistant/services/providers/openai/message_builder.rb

0.0% lines covered

100.0% branches covered

68 relevant lines. 0 lines covered and 68 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Providers
  4. module Openai
  5. # Builds OpenAI Responses API request options for assistant chat.
  6. class MessageBuilder
  7. MAX_HISTORY_MESSAGES = 20
  8. def initialize(thread:, question:, system_prompt:, allowed_tools:, media: [])
  9. @thread = thread
  10. @question = question.to_s
  11. @system_prompt = system_prompt.to_s
  12. @allowed_tools = Array(allowed_tools)
  13. @media = Array(media).compact
  14. end
  15. # @return [Hash] options hash for LlmProviders::OpenaiProvider#run
  16. def build_chat_options
  17. tools = Assistant::Tools::ToolSchemaAdapter.new(allowed_tools).for_openai
  18. previous_response_id = last_openai_response_id
  19. messages =
  20. if previous_response_id.present?
  21. [ { role: "user", content: question } ]
  22. else
  23. history = build_history_messages
  24. [ { role: "system", content: system_prompt } ] + history + [ { role: "user", content: question } ]
  25. end
  26. opts = {
  27. messages: messages,
  28. tools: tools,
  29. previous_response_id: previous_response_id,
  30. temperature: 0.2,
  31. max_tokens: 1200
  32. }
  33. opts[:media] = media if media.present?
  34. opts
  35. end
  36. # @return [Hash]
  37. def build_log_payload
  38. {
  39. provider: "openai",
  40. system: system_prompt.to_s,
  41. previous_response_id: last_openai_response_id,
  42. messages: build_history_messages + [ { role: "user", content: question } ],
  43. tools_count: allowed_tools.size
  44. }
  45. end
  46. private
  47. attr_reader :thread, :question, :system_prompt, :allowed_tools, :media
  48. def build_history_messages
  49. return [] if thread.nil?
  50. msgs = thread.messages.chronological.to_a.last(MAX_HISTORY_MESSAGES)
  51. msgs
  52. .select { |m| m.role.in?(%w[user assistant]) }
  53. .reject { |m| m.role == "assistant" && (m.metadata["pending_tool_followup"] == true || m.metadata[:pending_tool_followup] == true) }
  54. .reject { |m| m.role == "assistant" && (m.metadata["followup_for_assistant_message_id"].present? || m.metadata[:followup_for_assistant_message_id].present?) }
  55. .map { |m| { role: m.role, content: m.content.to_s } }
  56. end
  57. # Mirrors prior behavior in LlmResponder: reuse last response_id that is not awaiting tool outputs.
  58. def last_openai_response_id
  59. return nil if thread.nil?
  60. turns = thread.turns.order(created_at: :desc).where(provider_name: "openai").limit(25)
  61. eligible = turns.find do |t|
  62. state = t.provider_state || {}
  63. awaiting = state["awaiting_tool_outputs"]
  64. awaiting = state[:awaiting_tool_outputs] if awaiting.nil?
  65. awaiting != true
  66. end
  67. state = eligible&.provider_state || {}
  68. state["response_id"] || state[:response_id]
  69. end
  70. end
  71. end
  72. end
  73. end

app/domains/assistant/services/providers/openai/parser.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Providers
  4. module Openai
  5. # Normalizes OpenAI provider results into a stable internal shape.
  6. class Parser
  7. # @param provider_result [Hash]
  8. # @return [Hash]
  9. def self.normalize(provider_result)
  10. h = provider_result.is_a?(Hash) ? provider_result : {}
  11. {
  12. content: h[:content] || h["content"],
  13. tool_calls: h[:tool_calls] || h["tool_calls"] || [],
  14. response_id: h[:response_id] || h["response_id"]
  15. }.compact
  16. end
  17. end
  18. end
  19. end
  20. end

app/domains/assistant/services/providers/provider_router.rb

0.0% lines covered

100.0% branches covered

71 relevant lines. 0 lines covered and 71 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Providers
  4. # ProviderRouter centralizes provider-specific request building for assistant chat/tool calling.
  5. #
  6. # This keeps provider branching out of chat orchestration components.
  7. class ProviderRouter
  8. # @param thread [Assistant::ChatThread, nil]
  9. # @param question [String]
  10. # @param system_prompt [String]
  11. # @param allowed_tools [Array<Assistant::Tool>]
  12. # @param media [Array<Hash>]
  13. def initialize(thread:, question:, system_prompt:, allowed_tools:, media: [])
  14. @thread = thread
  15. @question = question.to_s
  16. @system_prompt = system_prompt.to_s
  17. @allowed_tools = Array(allowed_tools)
  18. @media = Array(media).compact
  19. end
  20. # @param provider [Object] LlmProviders::*Provider instance
  21. # @return [Hash] provider.run result
  22. def call(provider:)
  23. provider_name = provider.provider_name.to_s.downcase
  24. case provider_name
  25. when "openai"
  26. options = Providers::Openai::MessageBuilder.new(
  27. thread: thread,
  28. question: question,
  29. system_prompt: system_prompt,
  30. allowed_tools: allowed_tools,
  31. media: media
  32. ).build_chat_options
  33. provider.run(nil, options)
  34. when "anthropic"
  35. options = Providers::Anthropic::MessageBuilder.new(
  36. thread: thread,
  37. question: question,
  38. system_prompt: system_prompt,
  39. allowed_tools: allowed_tools,
  40. media: media
  41. ).build_chat_options
  42. provider.run(nil, options)
  43. else
  44. # Legacy prompt-based providers (not a focus right now).
  45. legacy_prompt = Assistant::Chat::Components::PromptBuilder.new(
  46. context: {},
  47. question: question,
  48. allowed_tools: allowed_tools,
  49. conversation_history: []
  50. ).build
  51. provider.run(legacy_prompt, system_message: system_prompt, temperature: 0.2, max_tokens: 1200)
  52. end
  53. end
  54. # @param provider [Object] LlmProviders::*Provider instance
  55. # @return [String] JSON payload for logging
  56. def request_payload_for_log(provider:)
  57. provider_name = provider.provider_name.to_s.downcase
  58. payload =
  59. case provider_name
  60. when "openai"
  61. Providers::Openai::MessageBuilder.new(
  62. thread: thread,
  63. question: question,
  64. system_prompt: system_prompt,
  65. allowed_tools: allowed_tools,
  66. media: media
  67. ).build_log_payload
  68. when "anthropic"
  69. Providers::Anthropic::MessageBuilder.new(
  70. thread: thread,
  71. question: question,
  72. system_prompt: system_prompt,
  73. allowed_tools: allowed_tools,
  74. media: media
  75. ).build_log_payload
  76. else
  77. { provider: provider.provider_name, system: system_prompt.to_s, question: question.to_s, tools_count: allowed_tools.size }
  78. end
  79. payload.to_json
  80. end
  81. private
  82. attr_reader :thread, :question, :system_prompt, :allowed_tools, :media
  83. end
  84. end
  85. end

app/domains/assistant/services/tools/arg_schema_validator.rb

0.0% lines covered

100.0% branches covered

52 relevant lines. 0 lines covered and 52 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Minimal JSON-schema-like validator for tool args.
  5. #
  6. # Supported schema keys:
  7. # - type: "object"
  8. # - required: ["field1", ...]
  9. # - properties: { "field" => { "type" => "string|integer|boolean|number|object|array" } }
  10. #
  11. # Intentionally minimal to avoid new dependencies.
  12. class ArgSchemaValidator
  13. def initialize(schema)
  14. @schema = schema.is_a?(Hash) ? schema : {}
  15. end
  16. def validate(args)
  17. args = args.is_a?(Hash) ? args : {}
  18. return [] if schema.blank?
  19. # Support top-level anyOf/oneOf (minimal): valid if any sub-schema validates.
  20. branches = Array(schema["anyOf"] || schema[:anyOf] || schema["oneOf"] || schema[:oneOf])
  21. if branches.any?
  22. branch_errors = branches.map { |sub| self.class.new(sub).validate(args) }
  23. return [] if branch_errors.any?(&:empty?)
  24. # Fall through and report errors from the first branch + base schema requirements/types.
  25. end
  26. errors = []
  27. errors.concat(validate_required(args))
  28. errors.concat(validate_types(args))
  29. errors
  30. end
  31. private
  32. attr_reader :schema
  33. def validate_required(args)
  34. required = Array(schema["required"] || schema[:required])
  35. required.filter_map do |key|
  36. k = key.to_s
  37. "#{k} is required" if args[k].nil? && args[key.to_sym].nil?
  38. end
  39. end
  40. def validate_types(args)
  41. props = schema["properties"] || schema[:properties]
  42. return [] unless props.is_a?(Hash)
  43. props.flat_map do |k, v|
  44. expected = (v.is_a?(Hash) ? (v["type"] || v[:type]) : nil)
  45. next [] if expected.blank?
  46. val = args[k.to_s]
  47. val = args[k.to_sym] if val.nil?
  48. next [] if val.nil?
  49. ok = case expected.to_s
  50. when "string" then val.is_a?(String)
  51. when "integer" then val.is_a?(Integer)
  52. when "number" then val.is_a?(Numeric)
  53. when "boolean" then val == true || val == false
  54. when "object" then val.is_a?(Hash)
  55. when "array" then val.is_a?(Array)
  56. else true
  57. end
  58. ok ? [] : [ "#{k} must be a #{expected}" ]
  59. end
  60. end
  61. end
  62. end
  63. end

app/domains/assistant/services/tools/runner.rb

0.0% lines covered

100.0% branches covered

87 relevant lines. 0 lines covered and 87 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "timeout"
  3. module Assistant
  4. module Tools
  5. # Executes a proposed tool execution with guardrails:
  6. # - tool exists/enabled
  7. # - schema validation
  8. # - confirmation required for write tools
  9. # - idempotency
  10. # - timeout
  11. # - structured results + audit record updates
  12. class Runner
  13. def initialize(user:, tool_execution:, approved_by: nil)
  14. @user = user
  15. @tool_execution = tool_execution
  16. @approved_by = approved_by
  17. end
  18. def call
  19. return already_done if tool_execution.status == "success"
  20. return fail!("unauthorized", "Not allowed") unless tool_execution.thread.user_id == user.id
  21. tool = Assistant::Tool.find_by(tool_key: tool_execution.tool_key)
  22. return fail!("tool_not_found", "Tool not found") if tool.nil?
  23. return fail!("tool_disabled", "Tool is disabled") unless tool.enabled?
  24. if tool_execution.requires_confirmation && approved_by.nil?
  25. return fail!("confirmation_required", "This tool requires confirmation")
  26. end
  27. errors = Assistant::Tools::ArgSchemaValidator.new(tool.arg_schema).validate(tool_execution.args)
  28. return fail!("schema_invalid", errors.join(", ")) if errors.any?
  29. tool_execution.update!(
  30. status: "running",
  31. started_at: Time.current,
  32. approved_by: approved_by,
  33. approved_at: approved_by.present? ? Time.current : nil
  34. )
  35. result = nil
  36. # Some tools (e.g. LLM-backed generators) can take longer than 60s.
  37. # Keep an upper bound to avoid indefinite hangs, but allow tools to opt into longer timeouts.
  38. Timeout.timeout((tool.timeout_ms.to_i / 1000.0).clamp(0.1, 300.0)) do
  39. result = execute_tool(tool, tool_execution.args)
  40. end
  41. tool_execution.update!(
  42. status: (result[:success] ? "success" : "error"),
  43. finished_at: Time.current,
  44. result: result[:data] || {},
  45. error: result[:error]
  46. )
  47. record_event("tool_executed", severity: result[:success] ? "info" : "error", payload: {
  48. tool_key: tool.tool_key,
  49. status: tool_execution.status,
  50. error: tool_execution.error
  51. }.compact)
  52. result
  53. rescue Timeout::Error
  54. fail!("timeout", "Tool execution timed out")
  55. rescue StandardError => e
  56. fail!("exception", e.message)
  57. end
  58. private
  59. attr_reader :user, :tool_execution, :approved_by
  60. def already_done
  61. { success: true, data: tool_execution.result }
  62. end
  63. def fail!(code, message)
  64. tool_execution.update!(
  65. status: "error",
  66. finished_at: Time.current,
  67. error: message
  68. ) if tool_execution.persisted? && tool_execution.status != "success"
  69. record_event("tool_denied", severity: "warn", payload: {
  70. tool_key: tool_execution.tool_key,
  71. reason: code,
  72. message: message
  73. })
  74. { success: false, error: message, error_type: code }
  75. end
  76. def execute_tool(tool, args)
  77. klass = tool.executor_class.safe_constantize
  78. return { success: false, error: "Invalid executor_class", error_type: "executor_missing" } if klass.nil?
  79. executor = klass.new(user: user)
  80. unless executor.respond_to?(:call)
  81. return { success: false, error: "Executor does not implement #call", error_type: "executor_invalid" }
  82. end
  83. executor.call(args: args, tool_execution: tool_execution)
  84. end
  85. def record_event(event_type, severity:, payload:)
  86. Assistant::Ops::Event.create!(
  87. thread: tool_execution.thread,
  88. trace_id: tool_execution.trace_id,
  89. event_type: event_type,
  90. severity: severity,
  91. payload: payload
  92. )
  93. rescue StandardError
  94. # Best-effort; don't fail tool execution because events can't be recorded.
  95. end
  96. end
  97. end
  98. end

app/domains/assistant/services/tools/tool_schema_adapter.rb

0.0% lines covered

100.0% branches covered

81 relevant lines. 0 lines covered and 81 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Builds provider-native tool schema payloads from Assistant::Tool records.
  5. class ToolSchemaAdapter
  6. def initialize(tools)
  7. @tools = Array(tools)
  8. end
  9. # @return [Array<Hash>] OpenAI Responses API tools payload
  10. def for_openai
  11. tools.map do |tool|
  12. schema = normalize_openai_json_schema(tool.arg_schema.presence || { type: "object", properties: {} })
  13. schema = sanitize_openai_top_level_schema(schema)
  14. {
  15. type: "function",
  16. # Responses API expects function tool definition fields at the top-level.
  17. # (Chat Completions uses {function: {...}}; Responses uses {name:, description:, parameters:}).
  18. name: tool.tool_key.to_s,
  19. description: tool.description.to_s,
  20. parameters: schema
  21. }
  22. end
  23. end
  24. # @return [Array<Hash>] Anthropic Messages API tools payload
  25. def for_anthropic
  26. tools.map do |tool|
  27. {
  28. name: tool.tool_key.to_s,
  29. description: tool.description.to_s,
  30. input_schema: tool.arg_schema.presence || { type: "object", properties: {} }
  31. }
  32. end
  33. end
  34. private
  35. attr_reader :tools
  36. # OpenAI validates JSON schema more strictly than our internal schema registry.
  37. # In particular, `type: "array"` must include an `items` schema.
  38. #
  39. # @param schema [Hash]
  40. # @return [Hash]
  41. def normalize_openai_json_schema(schema)
  42. return {} unless schema.is_a?(Hash)
  43. normalized = schema.deep_dup
  44. case normalized["type"] || normalized[:type]
  45. when "array"
  46. normalized["items"] ||= normalized[:items]
  47. normalized[:items] ||= normalized["items"]
  48. normalized["items"] ||= {}
  49. normalized[:items] ||= {}
  50. normalized["items"] = normalize_openai_json_schema(normalized["items"])
  51. normalized[:items] = normalized["items"]
  52. when "object"
  53. props = normalized["properties"] || normalized[:properties]
  54. if props.is_a?(Hash)
  55. props.each do |k, v|
  56. props[k] = normalize_openai_json_schema(v)
  57. end
  58. end
  59. normalized["properties"] = props if props
  60. normalized[:properties] = props if props
  61. end
  62. # Handle common combinators
  63. %w[anyOf oneOf allOf].each do |key|
  64. arr = normalized[key] || normalized[key.to_sym]
  65. next unless arr.is_a?(Array)
  66. normalized[key] = arr.map { |v| normalize_openai_json_schema(v) }
  67. normalized[key.to_sym] = normalized[key]
  68. end
  69. if (items = normalized["items"] || normalized[:items]).is_a?(Hash)
  70. normalized["items"] = normalize_openai_json_schema(items)
  71. normalized[:items] = normalized["items"]
  72. end
  73. normalized
  74. end
  75. # OpenAI Responses API function parameters currently reject top-level schema combinators.
  76. # Error example:
  77. # "Invalid schema ... must have type 'object' and not have 'oneOf'/'anyOf'/'allOf'/'enum'/'not' at the top level."
  78. #
  79. # We keep our richer internal schemas (used for server-side validation), but when sending tool
  80. # definitions to OpenAI we strip unsupported top-level keys. This prevents the entire request
  81. # from failing while still allowing nested enums, etc.
  82. #
  83. # @param schema [Hash]
  84. # @return [Hash]
  85. def sanitize_openai_top_level_schema(schema)
  86. return {} unless schema.is_a?(Hash)
  87. normalized = schema.deep_dup
  88. type = (normalized["type"] || normalized[:type]).to_s
  89. normalized["type"] = type.presence || "object"
  90. normalized[:type] = normalized["type"]
  91. # OpenAI requires top-level to be an object schema.
  92. unless normalized["type"] == "object"
  93. normalized["type"] = "object"
  94. normalized[:type] = "object"
  95. end
  96. %w[anyOf oneOf allOf not enum].each do |k|
  97. normalized.delete(k)
  98. normalized.delete(k.to_sym)
  99. end
  100. normalized
  101. end
  102. end
  103. end
  104. end

app/domains/assistant/tools/add_note_to_application_tool.rb

0.0% lines covered

100.0% branches covered

34 relevant lines. 0 lines covered and 34 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: append or replace notes on an interview application.
  5. class AddNoteToApplicationTool < BaseTool
  6. def call(args:, tool_execution:)
  7. application_uuid = (args["application_uuid"] || args[:application_uuid]).to_s
  8. application_id = (args["application_id"] || args[:application_id]).to_i
  9. note = (args["note"] || args[:note]).to_s
  10. mode = (args["mode"] || args[:mode] || "append").to_s
  11. if application_uuid.blank? && application_id.zero?
  12. return { success: false, error: "application_uuid or application_id is required" }
  13. end
  14. return { success: false, error: "note is blank" } if note.strip.blank?
  15. app =
  16. if application_uuid.present?
  17. user.interview_applications.find_by(uuid: application_uuid)
  18. else
  19. user.interview_applications.find_by(id: application_id)
  20. end
  21. return { success: false, error: "Interview application not found" } if app.nil?
  22. new_notes =
  23. if mode == "replace"
  24. note
  25. else
  26. existing = app.notes.to_s
  27. existing.blank? ? note : "#{existing}\n\n#{note}"
  28. end
  29. app.update!(notes: new_notes)
  30. { success: true, data: { application_uuid: app.uuid, application_id: app.id, notes_length: app.notes.to_s.length } }
  31. rescue StandardError => e
  32. { success: false, error: e.message }
  33. end
  34. end
  35. end
  36. end

app/domains/assistant/tools/add_target_company_tool.rb

0.0% lines covered

100.0% branches covered

70 relevant lines. 0 lines covered and 70 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: add a company to the user's target companies list.
  5. #
  6. # args:
  7. # - company_id (optional)
  8. # - company_name (optional, used to find-or-create)
  9. # - priority (optional)
  10. # - companies (optional, array of {company_id?, company_name?, priority?})
  11. class AddTargetCompanyTool < BaseTool
  12. def call(args:, tool_execution:)
  13. if args["companies"].is_a?(Array) || args[:companies].is_a?(Array)
  14. return add_many(args)
  15. end
  16. add_one(args)
  17. rescue ActiveRecord::RecordInvalid => e
  18. { success: false, error: e.record.errors.full_messages.join(", ") }
  19. rescue StandardError => e
  20. { success: false, error: e.message }
  21. end
  22. private
  23. def add_one(args)
  24. company = resolve_company(args)
  25. return { success: false, error: "Company not found" } if company.nil?
  26. utc = UserTargetCompany.find_or_initialize_by(user: user, company: company)
  27. if (args["priority"] || args[:priority]).present?
  28. utc.priority = (args["priority"] || args[:priority]).to_i
  29. end
  30. utc.save!
  31. {
  32. success: true,
  33. data: {
  34. company: { id: company.id, name: company.name },
  35. target_company: { id: utc.id, priority: utc.priority }
  36. }
  37. }
  38. end
  39. def add_many(args)
  40. items = args["companies"]
  41. items = args[:companies] if items.nil?
  42. items = Array(items)
  43. results = items.map do |item|
  44. item = item.is_a?(Hash) ? item : {}
  45. r = add_one(item)
  46. {
  47. input: item,
  48. success: r[:success] == true,
  49. data: r[:data],
  50. error: r[:error]
  51. }.compact
  52. rescue StandardError => e
  53. { input: item, success: false, error: e.message }
  54. end
  55. successes = results.count { |r| r[:success] == true }
  56. failures = results.count { |r| r[:success] == false }
  57. {
  58. success: failures.zero?,
  59. data: {
  60. added_count: successes,
  61. failed_count: failures,
  62. results: results
  63. }
  64. }
  65. end
  66. def resolve_company(args)
  67. company_id = (args["company_id"] || args[:company_id]).to_i
  68. if company_id.positive?
  69. found = Company.find_by(id: company_id)
  70. return found if found
  71. # If the model provided an ID that doesn't exist in our DB, fall back to name-based lookup/creation.
  72. end
  73. name = (args["company_name"] || args[:company_name]).to_s.strip
  74. return nil if name.blank?
  75. Company.where("lower(name) = ?", name.downcase).first || Company.create!(name: name)
  76. end
  77. end
  78. end
  79. end

app/domains/assistant/tools/add_target_domain_tool.rb

0.0% lines covered

100.0% branches covered

70 relevant lines. 0 lines covered and 70 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: add a domain to the user's target domains list.
  5. #
  6. # args:
  7. # - domain_id (optional)
  8. # - domain_name (optional, used to find-or-create)
  9. # - priority (optional)
  10. # - domains (optional, array of {domain_id?, domain_name?, priority?})
  11. class AddTargetDomainTool < BaseTool
  12. def call(args:, tool_execution:)
  13. if args["domains"].is_a?(Array) || args[:domains].is_a?(Array)
  14. return add_many(args)
  15. end
  16. add_one(args)
  17. rescue ActiveRecord::RecordInvalid => e
  18. { success: false, error: e.record.errors.full_messages.join(", ") }
  19. rescue StandardError => e
  20. { success: false, error: e.message }
  21. end
  22. private
  23. def add_one(args)
  24. domain = resolve_domain(args)
  25. return { success: false, error: "Domain not found or could not be created" } if domain.nil?
  26. utd = UserTargetDomain.find_or_initialize_by(user: user, domain: domain)
  27. if (args["priority"] || args[:priority]).present?
  28. utd.priority = (args["priority"] || args[:priority]).to_i
  29. end
  30. utd.save!
  31. {
  32. success: true,
  33. data: {
  34. domain: { id: domain.id, name: domain.name },
  35. target_domain: { id: utd.id, priority: utd.priority }
  36. }
  37. }
  38. end
  39. def add_many(args)
  40. items = args["domains"]
  41. items = args[:domains] if items.nil?
  42. items = Array(items)
  43. results = items.map do |item|
  44. item = item.is_a?(Hash) ? item : {}
  45. r = add_one(item)
  46. {
  47. input: item,
  48. success: r[:success] == true,
  49. data: r[:data],
  50. error: r[:error]
  51. }.compact
  52. rescue StandardError => e
  53. { input: item, success: false, error: e.message }
  54. end
  55. successes = results.count { |r| r[:success] == true }
  56. failures = results.count { |r| r[:success] == false }
  57. {
  58. success: failures.zero?,
  59. data: {
  60. added_count: successes,
  61. failed_count: failures,
  62. results: results
  63. }
  64. }
  65. end
  66. def resolve_domain(args)
  67. domain_id = (args["domain_id"] || args[:domain_id]).to_i
  68. if domain_id.positive?
  69. found = Domain.find_by(id: domain_id)
  70. return found if found
  71. # If the model provided an ID that doesn't exist in our DB, fall back to name-based lookup/creation.
  72. end
  73. name = (args["domain_name"] || args[:domain_name]).to_s.strip
  74. return nil if name.blank?
  75. Domain.where("lower(name) = ?", name.downcase).first || Domain.create!(name: name)
  76. end
  77. end
  78. end
  79. end

app/domains/assistant/tools/add_target_job_role_tool.rb

0.0% lines covered

100.0% branches covered

70 relevant lines. 0 lines covered and 70 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: add a job role to the user's target job roles list.
  5. #
  6. # args:
  7. # - job_role_id (optional)
  8. # - job_role_title (optional, used to find-or-create)
  9. # - priority (optional)
  10. # - job_roles (optional, array of {job_role_id?, job_role_title?, priority?})
  11. class AddTargetJobRoleTool < BaseTool
  12. def call(args:, tool_execution:)
  13. if args["job_roles"].is_a?(Array) || args[:job_roles].is_a?(Array)
  14. return add_many(args)
  15. end
  16. add_one(args)
  17. rescue ActiveRecord::RecordInvalid => e
  18. { success: false, error: e.record.errors.full_messages.join(", ") }
  19. rescue StandardError => e
  20. { success: false, error: e.message }
  21. end
  22. private
  23. def add_one(args)
  24. role = resolve_job_role(args)
  25. return { success: false, error: "Job role not found" } if role.nil?
  26. utjr = UserTargetJobRole.find_or_initialize_by(user: user, job_role: role)
  27. if (args["priority"] || args[:priority]).present?
  28. utjr.priority = (args["priority"] || args[:priority]).to_i
  29. end
  30. utjr.save!
  31. {
  32. success: true,
  33. data: {
  34. job_role: { id: role.id, title: role.title },
  35. target_job_role: { id: utjr.id, priority: utjr.priority }
  36. }
  37. }
  38. end
  39. def add_many(args)
  40. items = args["job_roles"]
  41. items = args[:job_roles] if items.nil?
  42. items = Array(items)
  43. results = items.map do |item|
  44. item = item.is_a?(Hash) ? item : {}
  45. r = add_one(item)
  46. {
  47. input: item,
  48. success: r[:success] == true,
  49. data: r[:data],
  50. error: r[:error]
  51. }.compact
  52. rescue StandardError => e
  53. { input: item, success: false, error: e.message }
  54. end
  55. successes = results.count { |r| r[:success] == true }
  56. failures = results.count { |r| r[:success] == false }
  57. {
  58. success: failures.zero?,
  59. data: {
  60. added_count: successes,
  61. failed_count: failures,
  62. results: results
  63. }
  64. }
  65. end
  66. def resolve_job_role(args)
  67. job_role_id = (args["job_role_id"] || args[:job_role_id]).to_i
  68. if job_role_id.positive?
  69. found = JobRole.find_by(id: job_role_id)
  70. return found if found
  71. # If the model provided an ID that doesn't exist in our DB, fall back to title-based lookup/creation.
  72. end
  73. title = (args["job_role_title"] || args[:job_role_title]).to_s.strip
  74. return nil if title.blank?
  75. JobRole.where("lower(title) = ?", title.downcase).first || JobRole.create!(title: title)
  76. end
  77. end
  78. end
  79. end

app/domains/assistant/tools/base_tool.rb

0.0% lines covered

100.0% branches covered

11 relevant lines. 0 lines covered and 11 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. class BaseTool
  5. def initialize(user:)
  6. @user = user
  7. end
  8. private
  9. attr_reader :user
  10. end
  11. end
  12. end

app/domains/assistant/tools/confirm_user_memory_tool.rb

0.0% lines covered

100.0% branches covered

42 relevant lines. 0 lines covered and 42 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Confirms a memory proposal and persists selected items into long-term memory.
  5. class ConfirmUserMemoryTool < BaseTool
  6. # args:
  7. # - proposal_id: integer
  8. # - accepted_keys: array[string]
  9. def call(args:, tool_execution:)
  10. proposal_id = args["proposal_id"] || args[:proposal_id]
  11. accepted_keys = Array(args["accepted_keys"] || args[:accepted_keys]).map(&:to_s)
  12. proposal = Assistant::Memory::MemoryProposal.find_by(id: proposal_id, user: user)
  13. return { success: false, error: "Proposal not found" } if proposal.nil?
  14. return { success: false, error: "Proposal is not pending" } unless proposal.status == "pending"
  15. items = Array(proposal.proposed_items)
  16. accepted = items.select { |i| accepted_keys.include?(i["key"].to_s) }
  17. rejected = items.reject { |i| accepted_keys.include?(i["key"].to_s) }
  18. ActiveRecord::Base.transaction do
  19. accepted.each do |item|
  20. key = item["key"].to_s
  21. value = item["value"].is_a?(Hash) ? item["value"] : { "value" => item["value"] }
  22. record = Assistant::Memory::UserMemory.find_or_initialize_by(user: user, key: key)
  23. record.value = value
  24. record.source = "user"
  25. record.confidence = 1.0
  26. record.last_confirmed_at = Time.current
  27. record.save!
  28. end
  29. proposal.update!(
  30. status: "accepted",
  31. confirmed_at: Time.current,
  32. confirmed_by: user
  33. )
  34. end
  35. {
  36. success: true,
  37. data: {
  38. accepted: accepted.map { |i| i["key"] },
  39. rejected: rejected.map { |i| i["key"] }
  40. }
  41. }
  42. rescue StandardError => e
  43. { success: false, error: e.message }
  44. end
  45. end
  46. end
  47. end

app/domains/assistant/tools/create_interview_round_tool.rb

0.0% lines covered

100.0% branches covered

58 relevant lines. 0 lines covered and 58 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: create/schedule an interview round on an application.
  5. #
  6. # Supports both future scheduling and retroactive entry:
  7. # - scheduled_at: when it is/was scheduled
  8. # - completed_at: when it happened (optional)
  9. class CreateInterviewRoundTool < BaseTool
  10. def call(args:, tool_execution:)
  11. application_uuid = (args["application_uuid"] || args[:application_uuid]).to_s
  12. return { success: false, error: "application_uuid is required" } if application_uuid.blank?
  13. app = user.interview_applications.find_by(uuid: application_uuid)
  14. return { success: false, error: "Interview application not found" } if app.nil?
  15. stage = (args["stage"] || args[:stage] || "screening").to_s
  16. result = (args["result"] || args[:result] || "pending").to_s
  17. round = app.interview_rounds.build(
  18. stage: stage,
  19. result: result,
  20. stage_name: (args["stage_name"] || args[:stage_name]),
  21. interviewer_name: (args["interviewer_name"] || args[:interviewer_name]),
  22. interviewer_role: (args["interviewer_role"] || args[:interviewer_role]),
  23. duration_minutes: (args["duration_minutes"] || args[:duration_minutes]),
  24. scheduled_at: parse_time(args["scheduled_at"] || args[:scheduled_at]),
  25. completed_at: parse_time(args["completed_at"] || args[:completed_at]),
  26. notes: (args["notes"] || args[:notes])
  27. )
  28. # Position = next available if not provided
  29. round.position = (args["position"] || args[:position]).to_i if (args["position"] || args[:position]).present?
  30. round.position ||= (app.interview_rounds.maximum(:position).to_i + 1)
  31. round.save!
  32. {
  33. success: true,
  34. data: {
  35. interview_round: {
  36. id: round.id,
  37. stage: round.stage,
  38. stage_name: round.stage_display_name,
  39. result: round.result,
  40. scheduled_at: round.scheduled_at,
  41. completed_at: round.completed_at,
  42. interviewer: round.interviewer_display,
  43. duration_minutes: round.duration_minutes
  44. },
  45. interview_application: {
  46. uuid: app.uuid,
  47. id: app.id
  48. }
  49. }
  50. }
  51. rescue ActiveRecord::RecordInvalid => e
  52. { success: false, error: e.record.errors.full_messages.join(", ") }
  53. rescue StandardError => e
  54. { success: false, error: e.message }
  55. end
  56. private
  57. def parse_time(value)
  58. return nil if value.blank?
  59. Time.zone.parse(value.to_s)
  60. rescue ArgumentError, TypeError
  61. nil
  62. end
  63. end
  64. end
  65. end

app/domains/assistant/tools/generate_interview_prep_tool.rb

0.0% lines covered

100.0% branches covered

91 relevant lines. 0 lines covered and 91 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: generate interview prep artifacts for a specific application.
  5. # Triggers background generation of match analysis, focus areas, strength positioning, and question framing.
  6. class GenerateInterviewPrepTool < BaseTool
  7. VALID_KINDS = InterviewPrepArtifact::KINDS.map(&:to_s).freeze
  8. def call(args:, tool_execution:)
  9. application_id = (args["application_id"] || args[:application_id]).to_i
  10. kinds = extract_kinds(args)
  11. if application_id.zero?
  12. return { success: false, error: "application_id is required" }
  13. end
  14. application = user.interview_applications.find_by(id: application_id)
  15. if application.nil?
  16. return { success: false, error: "Interview application not found" }
  17. end
  18. if kinds.empty?
  19. return { success: false, error: "At least one prep type must be specified. Valid types: #{VALID_KINDS.join(', ')}" }
  20. end
  21. # Generate artifacts synchronously (they have caching via inputs_digest)
  22. results = generate_artifacts(application, kinds)
  23. {
  24. success: results[:failures].zero?,
  25. data: {
  26. application: {
  27. id: application.id,
  28. company: application.display_company&.name,
  29. role: application.display_job_role&.title
  30. },
  31. generated: results[:generated],
  32. cached: results[:cached],
  33. failed: results[:failed_kinds],
  34. results: results[:details]
  35. }
  36. }
  37. rescue StandardError => e
  38. { success: false, error: e.message }
  39. end
  40. private
  41. def extract_kinds(args)
  42. # Allow specifying specific kinds or "all"
  43. kinds_arg = args["kinds"] || args[:kinds]
  44. if kinds_arg == "all" || kinds_arg.nil?
  45. return VALID_KINDS
  46. end
  47. Array(kinds_arg).map(&:to_s).select { |k| VALID_KINDS.include?(k) }
  48. end
  49. def generate_artifacts(application, kinds)
  50. generated = []
  51. cached = []
  52. failed_kinds = []
  53. details = {}
  54. kinds.each do |kind|
  55. service = service_for_kind(kind)
  56. next if service.nil?
  57. artifact = service.new(user: user, interview_application: application).call
  58. if artifact.computed?
  59. if artifact.saved_change_to_computed_at?
  60. generated << kind
  61. details[kind] = { status: "generated", computed_at: artifact.computed_at&.iso8601 }
  62. else
  63. cached << kind
  64. details[kind] = { status: "cached", computed_at: artifact.computed_at&.iso8601 }
  65. end
  66. else
  67. failed_kinds << kind
  68. details[kind] = { status: "failed", error: artifact.error_message }
  69. end
  70. rescue StandardError => e
  71. failed_kinds << kind
  72. details[kind] = { status: "failed", error: e.message }
  73. end
  74. {
  75. generated: generated,
  76. cached: cached,
  77. failed_kinds: failed_kinds,
  78. failures: failed_kinds.size,
  79. details: details
  80. }
  81. end
  82. def service_for_kind(kind)
  83. case kind.to_sym
  84. when :match_analysis
  85. InterviewPrep::GenerateMatchAnalysisService
  86. when :focus_areas
  87. InterviewPrep::GenerateFocusAreasService
  88. when :strength_positioning
  89. InterviewPrep::GenerateStrengthPositioningService
  90. when :question_framing
  91. InterviewPrep::GenerateQuestionFramingService
  92. end
  93. end
  94. end
  95. end
  96. end

app/domains/assistant/tools/get_interview_application_tool.rb

0.0% lines covered

100.0% branches covered

51 relevant lines. 0 lines covered and 51 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: fetch a single interview application for the current user.
  5. class GetInterviewApplicationTool < BaseTool
  6. def call(args:, tool_execution:)
  7. uuid = (args["application_uuid"] || args[:application_uuid]).to_s
  8. application_id = (args["application_id"] || args[:application_id]).to_i
  9. if uuid.blank? && application_id.zero?
  10. return { success: false, error: "application_uuid or application_id is required" }
  11. end
  12. app =
  13. if uuid.present?
  14. user.interview_applications.includes(:company, :job_role, interview_rounds: :interview_feedback).find_by(uuid: uuid)
  15. else
  16. user.interview_applications.includes(:company, :job_role, interview_rounds: :interview_feedback).find_by(id: application_id)
  17. end
  18. return { success: false, error: "Interview application not found" } if app.nil?
  19. rounds = app.interview_rounds.ordered.map { |r|
  20. {
  21. id: r.id,
  22. stage: r.stage,
  23. stage_name: r.stage_display_name,
  24. scheduled_at: r.scheduled_at,
  25. completed_at: r.completed_at,
  26. result: r.result,
  27. interviewer: r.interviewer_display,
  28. duration_minutes: r.duration_minutes,
  29. has_feedback: r.interview_feedback.present?
  30. }
  31. }
  32. {
  33. success: true,
  34. data: {
  35. application: {
  36. uuid: app.uuid,
  37. id: app.id,
  38. status: app.status,
  39. pipeline_stage: app.pipeline_stage,
  40. applied_at: app.applied_at,
  41. company: app.display_company&.name,
  42. job_role: app.display_job_role&.title,
  43. notes: app.notes
  44. },
  45. interview_rounds: rounds
  46. }
  47. }
  48. rescue StandardError => e
  49. { success: false, error: e.message }
  50. end
  51. end
  52. end
  53. end

app/domains/assistant/tools/get_interview_feedback_tool.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: fetch feedback for an interview round (if any).
  5. class GetInterviewFeedbackTool < BaseTool
  6. def call(args:, tool_execution:)
  7. round_id = (args["interview_round_id"] || args[:interview_round_id]).to_i
  8. return { success: false, error: "interview_round_id is required" } if round_id <= 0
  9. round = InterviewRound.includes(:interview_feedback, :interview_application).find_by(id: round_id)
  10. return { success: false, error: "Interview round not found" } if round.nil?
  11. return { success: false, error: "Not authorized" } unless round.interview_application.user_id == user.id
  12. fb = round.interview_feedback
  13. return { success: true, data: { interview_round_id: round.id, interview_feedback: nil } } if fb.nil?
  14. {
  15. success: true,
  16. data: {
  17. interview_round_id: round.id,
  18. interview_feedback: {
  19. id: fb.id,
  20. went_well: fb.went_well,
  21. to_improve: fb.to_improve,
  22. self_reflection: fb.self_reflection,
  23. interviewer_notes: fb.interviewer_notes,
  24. recommended_action: fb.recommended_action,
  25. tags: fb.tag_list,
  26. ai_summary: fb.ai_summary
  27. }
  28. }
  29. }
  30. rescue StandardError => e
  31. { success: false, error: e.message }
  32. end
  33. end
  34. end
  35. end

app/domains/assistant/tools/get_interview_prep_tool.rb

0.0% lines covered

100.0% branches covered

90 relevant lines. 0 lines covered and 90 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: get interview prep artifacts for a specific application.
  5. # Returns existing prep artifacts (match analysis, focus areas, strength positioning, question framing).
  6. class GetInterviewPrepTool < BaseTool
  7. def call(args:, tool_execution:)
  8. application_id = (args["application_id"] || args[:application_id]).to_i
  9. if application_id.zero?
  10. return { success: false, error: "application_id is required" }
  11. end
  12. application = user.interview_applications.find_by(id: application_id)
  13. if application.nil?
  14. return { success: false, error: "Interview application not found" }
  15. end
  16. artifacts = application.interview_prep_artifacts.includes(:llm_api_log)
  17. {
  18. success: true,
  19. data: {
  20. application: {
  21. id: application.id,
  22. company: application.display_company&.name,
  23. role: application.display_job_role&.title,
  24. status: application.status,
  25. pipeline_stage: application.pipeline_stage
  26. },
  27. prep_artifacts: format_artifacts(artifacts),
  28. has_all_artifacts: artifacts.computed.count == InterviewPrepArtifact::KINDS.size
  29. }
  30. }
  31. rescue StandardError => e
  32. { success: false, error: e.message }
  33. end
  34. private
  35. def format_artifacts(artifacts)
  36. result = {}
  37. InterviewPrepArtifact::KINDS.each do |kind|
  38. artifact = artifacts.find { |a| a.kind == kind.to_s }
  39. if artifact.nil?
  40. result[kind] = { status: "not_generated" }
  41. else
  42. result[kind] = format_artifact(artifact)
  43. end
  44. end
  45. result
  46. end
  47. def format_artifact(artifact)
  48. base = {
  49. status: artifact.status,
  50. computed_at: artifact.computed_at&.iso8601
  51. }
  52. if artifact.computed?
  53. base[:content] = format_content(artifact.kind, artifact.content)
  54. elsif artifact.failed?
  55. base[:error] = artifact.error_message
  56. end
  57. base
  58. end
  59. def format_content(kind, content)
  60. return {} unless content.is_a?(Hash)
  61. case kind.to_sym
  62. when :match_analysis
  63. {
  64. match_label: content["match_label"],
  65. strong_in: content["strong_in"],
  66. partial_in: content["partial_in"],
  67. missing_or_risky: content["missing_or_risky"],
  68. notes: content["notes"]
  69. }.compact
  70. when :focus_areas
  71. {
  72. areas: content["areas"],
  73. notes: content["notes"]
  74. }.compact
  75. when :strength_positioning
  76. {
  77. strengths: content["strengths"],
  78. positioning_tips: content["positioning_tips"],
  79. notes: content["notes"]
  80. }.compact
  81. when :question_framing
  82. {
  83. questions: content["questions"],
  84. frameworks: content["frameworks"],
  85. notes: content["notes"]
  86. }.compact
  87. else
  88. content
  89. end
  90. end
  91. end
  92. end
  93. end

app/domains/assistant/tools/get_next_interview_tool.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: return the user's next upcoming interview round (across all applications).
  5. class GetNextInterviewTool < BaseTool
  6. def call(args:, tool_execution:)
  7. round = user.interview_rounds
  8. .includes(:interview_application)
  9. .upcoming
  10. .order(:scheduled_at)
  11. .first
  12. return { success: true, data: { next_interview: nil } } if round.nil?
  13. app = round.interview_application
  14. {
  15. success: true,
  16. data: {
  17. next_interview: {
  18. interview_round: {
  19. id: round.id,
  20. stage: round.stage,
  21. stage_name: round.stage_display_name,
  22. scheduled_at: round.scheduled_at,
  23. interviewer: round.interviewer_display,
  24. duration_minutes: round.duration_minutes
  25. },
  26. interview_application: {
  27. uuid: app.uuid,
  28. id: app.id,
  29. company: app.display_company&.name,
  30. job_role: app.display_job_role&.title
  31. }
  32. }
  33. }
  34. }
  35. rescue StandardError => e
  36. { success: false, error: e.message }
  37. end
  38. end
  39. end
  40. end

app/domains/assistant/tools/get_profile_summary_tool.rb

0.0% lines covered

100.0% branches covered

91 relevant lines. 0 lines covered and 91 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: returns a compact profile + pipeline summary for the current user.
  5. # Includes career context (work history, resume summary, target domains).
  6. # NOTE: Does not expose email_address to the LLM for privacy.
  7. class GetProfileSummaryTool < BaseTool
  8. def call(args:, tool_execution:)
  9. skills_limit = (args["top_skills_limit"] || args[:top_skills_limit] || 10).to_i.clamp(1, 25)
  10. work_history_limit = (args["work_history_limit"] || args[:work_history_limit] || 5).to_i.clamp(1, 10)
  11. {
  12. success: true,
  13. data: {
  14. user: build_user_data,
  15. career: build_career_data(work_history_limit),
  16. target_lists: build_target_lists,
  17. pipeline: build_pipeline_data,
  18. top_skills: build_top_skills(skills_limit)
  19. }
  20. }
  21. rescue StandardError => e
  22. { success: false, error: e.message }
  23. end
  24. private
  25. def build_user_data
  26. {
  27. id: user.id,
  28. name: user.name,
  29. years_of_experience: user.years_of_experience,
  30. current_company: user.current_company&.name,
  31. current_job_role: user.current_job_role&.title,
  32. bio: user.bio.presence,
  33. social_profiles: {
  34. linkedin: user.linkedin_url.presence,
  35. github: user.github_url.presence,
  36. twitter: user.twitter_url.presence,
  37. portfolio: user.portfolio_url.presence,
  38. gitlab: user.gitlab_url.presence
  39. }.compact
  40. }.compact
  41. end
  42. def build_career_data(work_history_limit)
  43. resume = user.user_resumes.analyzed.recent_first.first
  44. {
  45. resume_summary: resume&.analysis_summary,
  46. strengths: Array(resume&.strengths).first(5),
  47. domains: Array(resume&.domains).first(5),
  48. work_history: build_work_history(work_history_limit)
  49. }.compact
  50. end
  51. def build_work_history(limit)
  52. user.user_work_experiences
  53. .reverse_chronological
  54. .limit(limit)
  55. .map do |exp|
  56. {
  57. id: exp.id,
  58. company: exp.display_company_name,
  59. role: exp.display_role_title,
  60. start_date: exp.start_date&.to_s,
  61. end_date: exp.end_date&.to_s,
  62. is_current: exp.current?,
  63. highlights: Array(exp.highlights).first(3),
  64. skills: exp.skill_tags.pluck(:name).first(5)
  65. }.compact
  66. end
  67. end
  68. def build_target_lists
  69. {
  70. companies_count: user.target_companies.count,
  71. companies: user.target_companies.limit(5).pluck(:name),
  72. job_roles_count: user.target_job_roles.count,
  73. job_roles: user.target_job_roles.limit(5).pluck(:title),
  74. domains_count: user.target_domains.count,
  75. domains: user.target_domains.limit(5).pluck(:name)
  76. }
  77. end
  78. def build_pipeline_data
  79. {
  80. active_applications_count: user.interview_applications.where(status: "active").count,
  81. applications_by_stage: user.interview_applications.group(:pipeline_stage).count
  82. }
  83. end
  84. def build_top_skills(limit)
  85. user.top_skills(limit: limit).includes(:skill_tag).map do |us|
  86. {
  87. skill: us.skill_tag&.name,
  88. aggregated_level: us.aggregated_level&.round(2),
  89. category: us.category
  90. }.compact
  91. end
  92. end
  93. end
  94. end
  95. end

app/domains/assistant/tools/get_skill_details_tool.rb

0.0% lines covered

100.0% branches covered

81 relevant lines. 0 lines covered and 81 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: get detailed information about a specific skill.
  5. class GetSkillDetailsTool < BaseTool
  6. def call(args:, tool_execution:)
  7. skill_id = (args["skill_id"] || args[:skill_id]).to_i
  8. skill_name = (args["skill_name"] || args[:skill_name]).to_s.strip
  9. user_skill = find_skill(skill_id, skill_name)
  10. if user_skill.nil?
  11. return { success: false, error: "Skill not found in your profile" }
  12. end
  13. {
  14. success: true,
  15. data: format_skill_detail(user_skill)
  16. }
  17. rescue StandardError => e
  18. { success: false, error: e.message }
  19. end
  20. private
  21. def find_skill(skill_id, skill_name)
  22. if skill_id.positive?
  23. skill = user.user_skills.find_by(id: skill_id)
  24. return skill if skill
  25. end
  26. return nil if skill_name.blank?
  27. user.user_skills
  28. .joins(:skill_tag)
  29. .where("lower(skill_tags.name) = ?", skill_name.downcase)
  30. .first
  31. end
  32. def format_skill_detail(user_skill)
  33. {
  34. id: user_skill.id,
  35. skill: {
  36. id: user_skill.skill_tag_id,
  37. name: user_skill.skill_tag&.name
  38. },
  39. category: user_skill.category,
  40. proficiency: {
  41. level: user_skill.aggregated_level&.round(2),
  42. label: user_skill.proficiency_label,
  43. is_strong: user_skill.strong?,
  44. is_developing: user_skill.developing?
  45. },
  46. evidence: {
  47. resume_count: user_skill.resume_count,
  48. confidence: user_skill.confidence_score&.round(2),
  49. confidence_percentage: user_skill.confidence_percentage,
  50. last_demonstrated_at: user_skill.last_demonstrated_at&.to_s,
  51. max_years_experience: user_skill.max_years_experience
  52. }.compact,
  53. work_experiences: skill_work_experiences(user_skill),
  54. source_resumes: source_resume_names(user_skill)
  55. }.compact
  56. end
  57. def skill_work_experiences(user_skill)
  58. # Find work experiences where this skill was used
  59. user.user_work_experiences
  60. .joins(:skill_tags)
  61. .where(skill_tags: { id: user_skill.skill_tag_id })
  62. .reverse_chronological
  63. .limit(5)
  64. .map do |exp|
  65. {
  66. company: exp.display_company_name,
  67. role: exp.display_role_title,
  68. dates: [ exp.start_date&.to_s, exp.end_date&.to_s ].compact.join(" - "),
  69. is_current: exp.current?
  70. }.compact
  71. end
  72. end
  73. def source_resume_names(user_skill)
  74. user_skill.source_resumes.limit(5).map do |resume|
  75. {
  76. id: resume.id,
  77. name: resume.name,
  78. analyzed_at: resume.analyzed_at&.iso8601
  79. }
  80. end
  81. end
  82. end
  83. end
  84. end

app/domains/assistant/tools/get_work_experience_tool.rb

0.0% lines covered

100.0% branches covered

53 relevant lines. 0 lines covered and 53 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: get details of a specific work experience.
  5. class GetWorkExperienceTool < BaseTool
  6. def call(args:, tool_execution:)
  7. experience_id = (args["experience_id"] || args[:experience_id]).to_i
  8. if experience_id.zero?
  9. return { success: false, error: "experience_id is required" }
  10. end
  11. experience = user.user_work_experiences.find_by(id: experience_id)
  12. if experience.nil?
  13. return { success: false, error: "Work experience not found" }
  14. end
  15. {
  16. success: true,
  17. data: format_experience_detail(experience)
  18. }
  19. rescue StandardError => e
  20. { success: false, error: e.message }
  21. end
  22. private
  23. def format_experience_detail(exp)
  24. {
  25. id: exp.id,
  26. company: {
  27. name: exp.display_company_name,
  28. id: exp.company_id
  29. },
  30. role: {
  31. title: exp.display_role_title,
  32. id: exp.job_role_id
  33. },
  34. start_date: exp.start_date&.to_s,
  35. end_date: exp.end_date&.to_s,
  36. is_current: exp.current?,
  37. duration_months: calculate_duration_months(exp),
  38. highlights: Array(exp.highlights),
  39. responsibilities: Array(exp.responsibilities),
  40. skills: exp.skill_tags.pluck(:name),
  41. source_type: exp.source_type,
  42. source_count: exp.source_count,
  43. created_at: exp.created_at&.iso8601,
  44. updated_at: exp.updated_at&.iso8601
  45. }.compact
  46. end
  47. def calculate_duration_months(exp)
  48. return nil unless exp.start_date
  49. end_date = exp.current? ? Date.current : (exp.end_date || Date.current)
  50. months = ((end_date.year - exp.start_date.year) * 12) + (end_date.month - exp.start_date.month)
  51. months.clamp(0, 600) # Cap at 50 years
  52. end
  53. end
  54. end
  55. end

app/domains/assistant/tools/list_interview_applications_tool.rb

0.0% lines covered

100.0% branches covered

54 relevant lines. 0 lines covered and 54 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: list interview applications for the current user.
  5. class ListInterviewApplicationsTool < BaseTool
  6. def call(args:, tool_execution:)
  7. status = normalize_filter_value(args["status"] || args[:status])
  8. stage = normalize_filter_value(args["pipeline_stage"] || args[:pipeline_stage])
  9. limit = (args["limit"] || args[:limit] || 20).to_i.clamp(1, 50)
  10. scope = user.interview_applications.includes(:company, :job_role).order(created_at: :desc)
  11. scope = scope.where(status: status) if status.present?
  12. scope = scope.where(pipeline_stage: stage) if stage.present?
  13. apps = scope.limit(limit)
  14. {
  15. success: true,
  16. data: {
  17. count: apps.size,
  18. applications: apps.map { |a|
  19. next_round = a.interview_rounds.upcoming.order(:scheduled_at).first
  20. {
  21. uuid: a.uuid,
  22. id: a.id,
  23. status: a.status,
  24. pipeline_stage: a.pipeline_stage,
  25. company: a.display_company&.name,
  26. job_role: a.display_job_role&.title,
  27. applied_at: a.applied_at,
  28. next_interview: next_round ? serialize_round(next_round) : nil
  29. }
  30. }
  31. }
  32. }
  33. rescue StandardError => e
  34. { success: false, error: e.message }
  35. end
  36. private
  37. # Normalizes "all"/blank filter values to nil.
  38. #
  39. # LLMs commonly emit sentinel values like "all" even when the schema doesn't require it.
  40. # Treat these as "no filter" so the tool behaves intuitively.
  41. #
  42. # @param value [Object]
  43. # @return [String, nil]
  44. def normalize_filter_value(value)
  45. v = value.to_s.strip
  46. return nil if v.blank?
  47. return nil if v.casecmp("all").zero?
  48. return nil if v.casecmp("any").zero?
  49. v
  50. end
  51. def serialize_round(round)
  52. {
  53. id: round.id,
  54. stage: round.stage,
  55. stage_name: round.stage_display_name,
  56. scheduled_at: round.scheduled_at,
  57. interviewer: round.interviewer_display,
  58. duration_minutes: round.duration_minutes
  59. }
  60. end
  61. end
  62. end
  63. end

app/domains/assistant/tools/list_skills_tool.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: list the user's skills with proficiency levels.
  5. class ListSkillsTool < BaseTool
  6. VALID_FILTERS = %w[strong moderate developing all].freeze
  7. def call(args:, tool_execution:)
  8. limit = (args["limit"] || args[:limit] || 25).to_i.clamp(1, 100)
  9. category = (args["category"] || args[:category]).to_s.presence
  10. filter = (args["filter"] || args[:filter] || "all").to_s
  11. skills = build_query(limit, category, filter)
  12. {
  13. success: true,
  14. data: {
  15. count: skills.size,
  16. total_count: user.user_skills.count,
  17. filter_applied: filter,
  18. category_filter: category,
  19. skills: skills.map { |us| format_skill(us) },
  20. categories: user.user_skills.distinct.pluck(:category).compact.sort
  21. }
  22. }
  23. rescue StandardError => e
  24. { success: false, error: e.message }
  25. end
  26. private
  27. def build_query(limit, category, filter)
  28. base = user.user_skills.includes(:skill_tag)
  29. # Apply category filter
  30. base = base.by_category(category) if category.present?
  31. # Apply proficiency filter
  32. base = case filter
  33. when "strong"
  34. base.strong_skills
  35. when "moderate"
  36. base.moderate_skills
  37. when "developing"
  38. base.developing_skills
  39. else
  40. base
  41. end
  42. base.by_level_desc.limit(limit)
  43. end
  44. def format_skill(user_skill)
  45. {
  46. id: user_skill.id,
  47. skill_id: user_skill.skill_tag_id,
  48. name: user_skill.skill_tag&.name,
  49. category: user_skill.category,
  50. proficiency_level: user_skill.aggregated_level&.round(2),
  51. proficiency_label: user_skill.proficiency_label,
  52. resume_count: user_skill.resume_count,
  53. confidence: user_skill.confidence_score&.round(2),
  54. last_demonstrated_at: user_skill.last_demonstrated_at&.to_s,
  55. is_strong: user_skill.strong?,
  56. is_developing: user_skill.developing?
  57. }.compact
  58. end
  59. end
  60. end
  61. end

app/domains/assistant/tools/list_target_companies_tool.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: list the user's target companies.
  5. class ListTargetCompaniesTool < BaseTool
  6. def call(args:, tool_execution:)
  7. limit = (args["limit"] || args[:limit] || 50).to_i.clamp(1, 100)
  8. target_companies = user.user_target_companies
  9. .includes(:company)
  10. .ordered
  11. .limit(limit)
  12. {
  13. success: true,
  14. data: {
  15. count: target_companies.size,
  16. target_companies: target_companies.map { |utc|
  17. {
  18. id: utc.id,
  19. company_id: utc.company_id,
  20. company_name: utc.company&.name,
  21. priority: utc.priority,
  22. created_at: utc.created_at&.iso8601
  23. }
  24. }
  25. }
  26. }
  27. rescue StandardError => e
  28. { success: false, error: e.message }
  29. end
  30. end
  31. end
  32. end

app/domains/assistant/tools/list_target_domains_tool.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: list the user's target domains.
  5. class ListTargetDomainsTool < BaseTool
  6. def call(args:, tool_execution:)
  7. limit = (args["limit"] || args[:limit] || 50).to_i.clamp(1, 100)
  8. target_domains = user.user_target_domains
  9. .includes(:domain)
  10. .ordered
  11. .limit(limit)
  12. {
  13. success: true,
  14. data: {
  15. count: target_domains.size,
  16. target_domains: target_domains.map { |utd|
  17. {
  18. id: utd.id,
  19. domain_id: utd.domain_id,
  20. domain_name: utd.domain&.name,
  21. priority: utd.priority,
  22. created_at: utd.created_at&.iso8601
  23. }
  24. }
  25. }
  26. }
  27. rescue StandardError => e
  28. { success: false, error: e.message }
  29. end
  30. end
  31. end
  32. end

app/domains/assistant/tools/list_target_job_roles_tool.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: list the user's target job roles.
  5. class ListTargetJobRolesTool < BaseTool
  6. def call(args:, tool_execution:)
  7. limit = (args["limit"] || args[:limit] || 50).to_i.clamp(1, 100)
  8. target_roles = user.user_target_job_roles
  9. .includes(:job_role)
  10. .ordered
  11. .limit(limit)
  12. {
  13. success: true,
  14. data: {
  15. count: target_roles.size,
  16. target_job_roles: target_roles.map { |utjr|
  17. {
  18. id: utjr.id,
  19. job_role_id: utjr.job_role_id,
  20. job_role_title: utjr.job_role&.title,
  21. priority: utjr.priority,
  22. created_at: utjr.created_at&.iso8601
  23. }
  24. }
  25. }
  26. }
  27. rescue StandardError => e
  28. { success: false, error: e.message }
  29. end
  30. end
  31. end
  32. end

app/domains/assistant/tools/list_work_history_tool.rb

0.0% lines covered

100.0% branches covered

49 relevant lines. 0 lines covered and 49 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Read-only: list the user's work history (UserWorkExperience records).
  5. class ListWorkHistoryTool < BaseTool
  6. def call(args:, tool_execution:)
  7. limit = (args["limit"] || args[:limit] || 20).to_i.clamp(1, 50)
  8. include_skills = args["include_skills"] != false && args[:include_skills] != false
  9. experiences = user.user_work_experiences
  10. .reverse_chronological
  11. .limit(limit)
  12. experiences = experiences.includes(:skill_tags) if include_skills
  13. {
  14. success: true,
  15. data: {
  16. count: experiences.size,
  17. total_count: user.user_work_experiences.count,
  18. work_history: experiences.map { |exp| format_experience(exp, include_skills) }
  19. }
  20. }
  21. rescue StandardError => e
  22. { success: false, error: e.message }
  23. end
  24. private
  25. def format_experience(exp, include_skills)
  26. result = {
  27. id: exp.id,
  28. company: exp.display_company_name,
  29. role: exp.display_role_title,
  30. start_date: exp.start_date&.to_s,
  31. end_date: exp.end_date&.to_s,
  32. is_current: exp.current?,
  33. duration_months: calculate_duration_months(exp),
  34. highlights: Array(exp.highlights).first(5),
  35. responsibilities: Array(exp.responsibilities).first(5),
  36. source_type: exp.source_type
  37. }.compact
  38. if include_skills
  39. result[:skills] = exp.skill_tags.pluck(:name)
  40. end
  41. result
  42. end
  43. def calculate_duration_months(exp)
  44. return nil unless exp.start_date
  45. end_date = exp.current? ? Date.current : (exp.end_date || Date.current)
  46. months = ((end_date.year - exp.start_date.year) * 12) + (end_date.month - exp.start_date.month)
  47. months.clamp(0, 600) # Cap at 50 years
  48. end
  49. end
  50. end
  51. end

app/domains/assistant/tools/remove_target_company_tool.rb

0.0% lines covered

100.0% branches covered

61 relevant lines. 0 lines covered and 61 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: remove one or more companies from the user's target companies list.
  5. #
  6. # args:
  7. # - company_id (optional)
  8. # - company_name (optional, used to find)
  9. # - companies (optional, array of {company_id?, company_name?})
  10. class RemoveTargetCompanyTool < BaseTool
  11. def call(args:, tool_execution:)
  12. if args["companies"].is_a?(Array) || args[:companies].is_a?(Array)
  13. return remove_many(args)
  14. end
  15. remove_one(args)
  16. rescue StandardError => e
  17. { success: false, error: e.message }
  18. end
  19. private
  20. def remove_one(args)
  21. company = find_company(args)
  22. return { success: false, error: "Company not found" } if company.nil?
  23. utc = UserTargetCompany.find_by(user: user, company: company)
  24. if utc
  25. utc.destroy!
  26. { success: true, data: { removed: true, company: { id: company.id, name: company.name } } }
  27. else
  28. # Idempotent: "already removed" is a success.
  29. { success: true, data: { removed: false, company: { id: company.id, name: company.name } } }
  30. end
  31. end
  32. def remove_many(args)
  33. items = args["companies"]
  34. items = args[:companies] if items.nil?
  35. items = Array(items)
  36. results = items.map do |item|
  37. item = item.is_a?(Hash) ? item : {}
  38. r = remove_one(item)
  39. {
  40. input: item,
  41. success: r[:success] == true,
  42. data: r[:data],
  43. error: r[:error]
  44. }.compact
  45. rescue StandardError => e
  46. { input: item, success: false, error: e.message }
  47. end
  48. successes = results.count { |r| r[:success] == true }
  49. failures = results.count { |r| r[:success] == false }
  50. {
  51. success: failures.zero?,
  52. data: {
  53. removed_count: results.count { |r| r.dig(:data, :removed) == true },
  54. not_found_or_noop_count: results.count { |r| r[:success] == true && r.dig(:data, :removed) == false },
  55. failed_count: failures,
  56. results: results
  57. }
  58. }
  59. end
  60. def find_company(args)
  61. company_id = (args["company_id"] || args[:company_id]).to_i
  62. return Company.find_by(id: company_id) if company_id.positive?
  63. name = (args["company_name"] || args[:company_name]).to_s.strip
  64. return nil if name.blank?
  65. Company.where("lower(name) = ?", name.downcase).first
  66. end
  67. end
  68. end
  69. end

app/domains/assistant/tools/remove_target_domain_tool.rb

0.0% lines covered

100.0% branches covered

61 relevant lines. 0 lines covered and 61 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: remove one or more domains from the user's target domains list.
  5. #
  6. # args:
  7. # - domain_id (optional)
  8. # - domain_name (optional, used to find)
  9. # - domains (optional, array of {domain_id?, domain_name?})
  10. class RemoveTargetDomainTool < BaseTool
  11. def call(args:, tool_execution:)
  12. if args["domains"].is_a?(Array) || args[:domains].is_a?(Array)
  13. return remove_many(args)
  14. end
  15. remove_one(args)
  16. rescue StandardError => e
  17. { success: false, error: e.message }
  18. end
  19. private
  20. def remove_one(args)
  21. domain = find_domain(args)
  22. return { success: false, error: "Domain not found" } if domain.nil?
  23. utd = UserTargetDomain.find_by(user: user, domain: domain)
  24. if utd
  25. utd.destroy!
  26. { success: true, data: { removed: true, domain: { id: domain.id, name: domain.name } } }
  27. else
  28. # Idempotent: "already removed" is a success.
  29. { success: true, data: { removed: false, domain: { id: domain.id, name: domain.name } } }
  30. end
  31. end
  32. def remove_many(args)
  33. items = args["domains"]
  34. items = args[:domains] if items.nil?
  35. items = Array(items)
  36. results = items.map do |item|
  37. item = item.is_a?(Hash) ? item : {}
  38. r = remove_one(item)
  39. {
  40. input: item,
  41. success: r[:success] == true,
  42. data: r[:data],
  43. error: r[:error]
  44. }.compact
  45. rescue StandardError => e
  46. { input: item, success: false, error: e.message }
  47. end
  48. successes = results.count { |r| r[:success] == true }
  49. failures = results.count { |r| r[:success] == false }
  50. {
  51. success: failures.zero?,
  52. data: {
  53. removed_count: results.count { |r| r.dig(:data, :removed) == true },
  54. not_found_or_noop_count: results.count { |r| r[:success] == true && r.dig(:data, :removed) == false },
  55. failed_count: failures,
  56. results: results
  57. }
  58. }
  59. end
  60. def find_domain(args)
  61. domain_id = (args["domain_id"] || args[:domain_id]).to_i
  62. return Domain.find_by(id: domain_id) if domain_id.positive?
  63. name = (args["domain_name"] || args[:domain_name]).to_s.strip
  64. return nil if name.blank?
  65. Domain.where("lower(name) = ?", name.downcase).first
  66. end
  67. end
  68. end
  69. end

app/domains/assistant/tools/remove_target_job_role_tool.rb

0.0% lines covered

100.0% branches covered

60 relevant lines. 0 lines covered and 60 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: remove one or more job roles from the user's target job roles list.
  5. #
  6. # args:
  7. # - job_role_id (optional)
  8. # - job_role_title (optional, used to find)
  9. # - job_roles (optional, array of {job_role_id?, job_role_title?})
  10. class RemoveTargetJobRoleTool < BaseTool
  11. def call(args:, tool_execution:)
  12. if args["job_roles"].is_a?(Array) || args[:job_roles].is_a?(Array)
  13. return remove_many(args)
  14. end
  15. remove_one(args)
  16. rescue StandardError => e
  17. { success: false, error: e.message }
  18. end
  19. private
  20. def remove_one(args)
  21. role = find_job_role(args)
  22. return { success: false, error: "Job role not found" } if role.nil?
  23. utjr = UserTargetJobRole.find_by(user: user, job_role: role)
  24. if utjr
  25. utjr.destroy!
  26. { success: true, data: { removed: true, job_role: { id: role.id, title: role.title } } }
  27. else
  28. { success: true, data: { removed: false, job_role: { id: role.id, title: role.title } } }
  29. end
  30. end
  31. def remove_many(args)
  32. items = args["job_roles"]
  33. items = args[:job_roles] if items.nil?
  34. items = Array(items)
  35. results = items.map do |item|
  36. item = item.is_a?(Hash) ? item : {}
  37. r = remove_one(item)
  38. {
  39. input: item,
  40. success: r[:success] == true,
  41. data: r[:data],
  42. error: r[:error]
  43. }.compact
  44. rescue StandardError => e
  45. { input: item, success: false, error: e.message }
  46. end
  47. failures = results.count { |r| r[:success] == false }
  48. {
  49. success: failures.zero?,
  50. data: {
  51. removed_count: results.count { |r| r.dig(:data, :removed) == true },
  52. not_found_or_noop_count: results.count { |r| r[:success] == true && r.dig(:data, :removed) == false },
  53. failed_count: failures,
  54. results: results
  55. }
  56. }
  57. end
  58. def find_job_role(args)
  59. job_role_id = (args["job_role_id"] || args[:job_role_id]).to_i
  60. return JobRole.find_by(id: job_role_id) if job_role_id.positive?
  61. title = (args["job_role_title"] || args[:job_role_title]).to_s.strip
  62. return nil if title.blank?
  63. JobRole.where("lower(title) = ?", title.downcase).first
  64. end
  65. end
  66. end
  67. end

app/domains/assistant/tools/update_profile_tool.rb

0.0% lines covered

100.0% branches covered

106 relevant lines. 0 lines covered and 106 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: update user profile attributes.
  5. #
  6. # Updateable attributes:
  7. # - years_of_experience (integer)
  8. # - current_company_name (string, finds or creates Company)
  9. # - current_job_role_title (string, finds or creates JobRole)
  10. # - linkedin_url, github_url, twitter_url, portfolio_url, gitlab_url (strings)
  11. # - bio (text)
  12. #
  13. # NOTE: Does not allow updating email or password for security.
  14. class UpdateProfileTool < BaseTool
  15. ALLOWED_ATTRIBUTES = %w[
  16. years_of_experience
  17. current_company_name
  18. current_job_role_title
  19. linkedin_url
  20. github_url
  21. twitter_url
  22. portfolio_url
  23. gitlab_url
  24. bio
  25. ].freeze
  26. def call(args:, tool_execution:)
  27. updates = extract_updates(args)
  28. if updates.empty?
  29. return { success: false, error: "No valid attributes provided to update" }
  30. end
  31. changes = apply_updates(updates)
  32. {
  33. success: true,
  34. data: {
  35. updated_attributes: changes.keys,
  36. profile: build_profile_summary
  37. }
  38. }
  39. rescue ActiveRecord::RecordInvalid => e
  40. { success: false, error: e.record.errors.full_messages.join(", ") }
  41. rescue StandardError => e
  42. { success: false, error: e.message }
  43. end
  44. private
  45. def extract_updates(args)
  46. updates = {}
  47. ALLOWED_ATTRIBUTES.each do |attr|
  48. value = args[attr] || args[attr.to_sym]
  49. updates[attr] = value if value.present? || value == "" # Allow clearing with empty string
  50. end
  51. updates
  52. end
  53. def apply_updates(updates)
  54. changes = {}
  55. # Handle company - find or create
  56. if updates.key?("current_company_name")
  57. company_name = updates["current_company_name"].to_s.strip
  58. if company_name.blank?
  59. user.current_company = nil
  60. changes[:current_company] = nil
  61. else
  62. company = Company.where("lower(name) = ?", company_name.downcase).first ||
  63. Company.create!(name: company_name)
  64. user.current_company = company
  65. changes[:current_company] = company.name
  66. end
  67. end
  68. # Handle job role - find or create
  69. if updates.key?("current_job_role_title")
  70. role_title = updates["current_job_role_title"].to_s.strip
  71. if role_title.blank?
  72. user.current_job_role = nil
  73. changes[:current_job_role] = nil
  74. else
  75. job_role = JobRole.where("lower(title) = ?", role_title.downcase).first ||
  76. JobRole.create!(title: role_title)
  77. user.current_job_role = job_role
  78. changes[:current_job_role] = job_role.title
  79. end
  80. end
  81. # Handle years of experience
  82. if updates.key?("years_of_experience")
  83. value = updates["years_of_experience"]
  84. user.years_of_experience = value.present? ? value.to_i.clamp(0, 60) : nil
  85. changes[:years_of_experience] = user.years_of_experience
  86. end
  87. # Handle social URLs
  88. %w[linkedin_url github_url twitter_url portfolio_url gitlab_url].each do |attr|
  89. if updates.key?(attr)
  90. value = updates[attr].to_s.strip
  91. user.send("#{attr}=", value.presence)
  92. changes[attr.to_sym] = value.presence
  93. end
  94. end
  95. # Handle bio
  96. if updates.key?("bio")
  97. value = updates["bio"].to_s.strip
  98. user.bio = value.presence
  99. changes[:bio] = value.present? ? "updated" : "cleared"
  100. end
  101. user.save!
  102. changes
  103. end
  104. def build_profile_summary
  105. {
  106. name: user.name,
  107. years_of_experience: user.years_of_experience,
  108. current_company: user.current_company&.name,
  109. current_job_role: user.current_job_role&.title,
  110. bio: user.bio.present? ? user.bio.truncate(100) : nil,
  111. social_profiles: {
  112. linkedin: user.linkedin_url.presence,
  113. github: user.github_url.presence,
  114. twitter: user.twitter_url.presence,
  115. portfolio: user.portfolio_url.presence,
  116. gitlab: user.gitlab_url.presence
  117. }.compact
  118. }.compact
  119. end
  120. end
  121. end
  122. end

app/domains/assistant/tools/upsert_interview_feedback_tool.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Assistant
  3. module Tools
  4. # Write: create or update manual interview feedback for a round.
  5. class UpsertInterviewFeedbackTool < BaseTool
  6. def call(args:, tool_execution:)
  7. round_id = (args["interview_round_id"] || args[:interview_round_id]).to_i
  8. return { success: false, error: "interview_round_id is required" } if round_id <= 0
  9. round = InterviewRound.includes(:interview_application, :interview_feedback).find_by(id: round_id)
  10. return { success: false, error: "Interview round not found" } if round.nil?
  11. return { success: false, error: "Not authorized" } unless round.interview_application.user_id == user.id
  12. fb = round.interview_feedback || round.build_interview_feedback
  13. fb.went_well = args["went_well"] || args[:went_well] if args.key?("went_well") || args.key?(:went_well)
  14. fb.to_improve = args["to_improve"] || args[:to_improve] if args.key?("to_improve") || args.key?(:to_improve)
  15. fb.self_reflection = args["self_reflection"] || args[:self_reflection] if args.key?("self_reflection") || args.key?(:self_reflection)
  16. fb.interviewer_notes = args["interviewer_notes"] || args[:interviewer_notes] if args.key?("interviewer_notes") || args.key?(:interviewer_notes)
  17. fb.recommended_action = args["recommended_action"] || args[:recommended_action] if args.key?("recommended_action") || args.key?(:recommended_action)
  18. tags = args["tags"] || args[:tags]
  19. fb.tag_list = tags if tags.present?
  20. fb.save!
  21. {
  22. success: true,
  23. data: {
  24. interview_round_id: round.id,
  25. interview_feedback_id: fb.id
  26. }
  27. }
  28. rescue ActiveRecord::RecordInvalid => e
  29. { success: false, error: e.record.errors.full_messages.join(", ") }
  30. rescue StandardError => e
  31. { success: false, error: e.message }
  32. end
  33. end
  34. end
  35. end

app/domains/signals/contracts/validators/json_schema_validator.rb

0.0% lines covered

100.0% branches covered

39 relevant lines. 0 lines covered and 39 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "json_schemer"
  3. module Signals
  4. module Contracts
  5. module Validators
  6. # Validates JSON objects against Draft 2020-12 JSON Schemas.
  7. #
  8. # Schemas in this repo use stable `$id` URIs like:
  9. # gleania://signals/contracts/schemas/decision_input.schema.json
  10. #
  11. # This validator resolves those `$ref`s to files under:
  12. # app/domains/signals/contracts/schemas/
  13. class JsonSchemaValidator
  14. SCHEMA_ID_PREFIX = "gleania://signals/contracts/schemas/".freeze
  15. def initialize(schema_id:)
  16. @schema_id = schema_id
  17. end
  18. def valid?(data)
  19. errors_for(data).empty?
  20. end
  21. # @return [Array<Hash>] json_schemer error hashes
  22. def errors_for(data)
  23. schemer.validate(data).to_a
  24. end
  25. private
  26. attr_reader :schema_id
  27. def schemer
  28. @schemer ||= JSONSchemer.schema(root_schema_json, ref_resolver: method(:resolve_ref))
  29. end
  30. def root_schema_json
  31. JSON.parse(File.read(path_for_schema_id(schema_id)))
  32. end
  33. # Resolve `$ref` schema IDs (gleania://...) to local files.
  34. def resolve_ref(uri)
  35. uri_str = uri.to_s
  36. base_uri = uri_str.split("#", 2).first
  37. return nil unless base_uri.start_with?(SCHEMA_ID_PREFIX)
  38. path = path_for_schema_id(base_uri)
  39. JSON.parse(File.read(path))
  40. end
  41. def path_for_schema_id(uri)
  42. raise ArgumentError, "Unsupported schema id: #{uri}" unless uri.start_with?(SCHEMA_ID_PREFIX)
  43. relative = uri.delete_prefix(SCHEMA_ID_PREFIX)
  44. File.join(Rails.root, "app/domains/signals/contracts/schemas", relative)
  45. end
  46. end
  47. end
  48. end
  49. end

app/domains/signals/services/decisioning/decision_input_builder.rb

0.0% lines covered

100.0% branches covered

194 relevant lines. 0 lines covered and 194 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. # Builds a schema-valid DecisionInput using the current application/email state.
  5. #
  6. # Note: This is a *builder* only. It does not write to the DB.
  7. class DecisionInputBuilder
  8. VERSION = "2026-01-27".freeze
  9. def initialize(synced_email)
  10. @synced_email = synced_email
  11. end
  12. def build(facts: nil)
  13. base = build_base
  14. app = synced_email.interview_application
  15. base.merge("facts" => (facts || build_fallback_facts(app)))
  16. end
  17. # Base DecisionInput without facts (used by EmailFacts extraction).
  18. def build_base
  19. app = synced_email.interview_application
  20. event = Signals::Facts::CanonicalEmailEventBuilder.new(synced_email).build
  21. {
  22. "version" => VERSION,
  23. "event" => event,
  24. "match" => build_match(app),
  25. "application" => app ? build_application_snapshot(app) : nil
  26. }
  27. end
  28. private
  29. attr_reader :synced_email
  30. def build_match(app)
  31. {
  32. "matched" => synced_email.matched?,
  33. "match_strategy" => nil,
  34. "interview_application_id" => app&.id,
  35. "confidence" => synced_email.matched? ? 0.5 : 0.0
  36. }
  37. end
  38. def build_application_snapshot(app)
  39. rounds = app.interview_rounds.ordered.last(10).map do |r|
  40. {
  41. "id" => r.id,
  42. "position" => r.position,
  43. "stage" => r.stage,
  44. "stage_name" => r.stage_name,
  45. "scheduled_at" => r.scheduled_at&.iso8601,
  46. "result" => r.result,
  47. "interviewer_name" => r.interviewer_name,
  48. "source_email_id" => r.source_email_id
  49. }
  50. end
  51. {
  52. "id" => app.id,
  53. "status" => app.status,
  54. "pipeline_stage" => app.pipeline_stage,
  55. "company" => {
  56. "id" => app.company_id,
  57. "name" => app.company&.name,
  58. "website" => app.company&.website
  59. },
  60. "job_role" => {
  61. "id" => app.job_role_id,
  62. "title" => app.job_role&.title
  63. },
  64. "rounds_recent" => rounds
  65. }
  66. end
  67. public
  68. def build_fallback_facts(app)
  69. email_type = synced_email.email_type.to_s
  70. kind = map_kind(email_type)
  71. {
  72. "extraction" => {
  73. "provider" => nil,
  74. "model" => nil,
  75. "confidence" => (synced_email.extraction_confidence || 0.0).to_f,
  76. "warnings" => []
  77. },
  78. "classification" => {
  79. "kind" => kind,
  80. "confidence" => kind == "unknown" ? 0.0 : 0.5,
  81. "evidence" => [synced_email.subject.to_s.presence || synced_email.snippet.to_s.presence || "classified"].compact
  82. },
  83. "entities" => {
  84. "company" => {
  85. "name" => synced_email.signal_company_name || app&.company&.name,
  86. "website" => synced_email.signal_company_website || app&.company&.website
  87. },
  88. "recruiter" => {
  89. "name" => synced_email.signal_recruiter_name,
  90. "email" => synced_email.signal_recruiter_email,
  91. "title" => synced_email.signal_recruiter_title
  92. },
  93. "job" => {
  94. "title" => synced_email.signal_job_title || app&.job_role&.title,
  95. "department" => synced_email.signal_job_department,
  96. "location" => synced_email.signal_job_location,
  97. "url" => synced_email.signal_job_url
  98. }
  99. },
  100. "action_links" => Array(synced_email.signal_action_links).map do |l|
  101. next unless l.is_a?(Hash)
  102. url = l["url"].to_s
  103. label = l["action_label"].to_s
  104. next if url.blank? || label.blank?
  105. { "url" => url, "action_label" => label, "priority" => (l["priority"] || 5).to_i }
  106. end.compact.first(20),
  107. "key_insights" => synced_email.extracted_data&.dig("key_insights"),
  108. "is_forwarded" => !!synced_email.extracted_data&.dig("is_forwarded"),
  109. "scheduling" => empty_scheduling,
  110. "round_feedback" => empty_round_feedback,
  111. "status_change" => status_change_stub(email_type)
  112. }
  113. end
  114. private
  115. def map_kind(email_type)
  116. case email_type
  117. when "scheduling", "interview_reminder" then "scheduling"
  118. when "interview_invite" then "interview_invite"
  119. when "round_feedback" then "round_feedback"
  120. when "rejection", "offer" then "status_update"
  121. when "application_confirmation" then "application_confirmation"
  122. when "recruiter_outreach" then "recruiter_outreach"
  123. when "assessment" then "interview_assessment"
  124. when "", nil then "unknown"
  125. else "other"
  126. end
  127. end
  128. def empty_scheduling
  129. {
  130. "is_scheduling_related" => false,
  131. "scheduled_at" => nil,
  132. "timezone_hint" => nil,
  133. "duration_minutes" => 0,
  134. "stage" => nil,
  135. "round_type" => nil,
  136. "stage_name" => nil,
  137. "interviewer_name" => nil,
  138. "interviewer_role" => nil,
  139. "video_link" => nil,
  140. "phone_number" => nil,
  141. "location" => nil,
  142. "is_rescheduled" => false,
  143. "is_cancelled" => false,
  144. "original_scheduled_at" => nil,
  145. "evidence" => []
  146. }
  147. end
  148. def empty_round_feedback
  149. {
  150. "has_round_feedback" => false,
  151. "result" => nil,
  152. "stage_mentioned" => nil,
  153. "round_type" => nil,
  154. "interviewer_mentioned" => nil,
  155. "date_mentioned" => nil,
  156. "feedback" => {
  157. "has_detailed_feedback" => false,
  158. "summary" => nil,
  159. "strengths" => [],
  160. "improvements" => [],
  161. "full_feedback_text" => nil
  162. },
  163. "next_steps" => {
  164. "has_next_round" => false,
  165. "next_round_type" => nil,
  166. "next_round_hint" => nil,
  167. "timeline_hint" => nil
  168. },
  169. "evidence" => []
  170. }
  171. end
  172. def status_change_stub(email_type)
  173. type = case email_type
  174. when "rejection" then "rejection"
  175. when "offer" then "offer"
  176. else "no_change"
  177. end
  178. {
  179. "has_status_change" => %w[rejection offer].include?(email_type),
  180. "type" => type,
  181. "is_final" => (email_type == "rejection") ? true : nil,
  182. "effective_date" => synced_email.email_date&.iso8601,
  183. "rejection_details" => { "reason" => nil, "stage_rejected_at" => nil, "is_generic" => false, "door_open" => false },
  184. "offer_details" => {
  185. "role_title" => nil,
  186. "department" => nil,
  187. "start_date" => nil,
  188. "response_deadline" => nil,
  189. "includes_compensation_info" => false,
  190. "compensation_hints" => nil,
  191. "next_steps" => nil
  192. },
  193. "feedback" => { "has_feedback" => false, "feedback_text" => nil, "is_constructive" => false },
  194. "evidence" => []
  195. }
  196. end
  197. end
  198. end
  199. end

app/domains/signals/services/decisioning/execution/dispatcher.rb

0.0% lines covered

100.0% branches covered

104 relevant lines. 0 lines covered and 104 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. class Dispatcher
  6. def initialize(synced_email, pipeline_recorder: nil)
  7. @synced_email = synced_email
  8. @pipeline_recorder = pipeline_recorder
  9. end
  10. def dispatch(step)
  11. action = step["action"].to_s
  12. return nil if action == "noop"
  13. if requires_application?(action) && !synced_email.matched?
  14. res = {
  15. "step_id" => step["step_id"],
  16. "action" => action,
  17. "status" => "skipped_no_application"
  18. }
  19. emit_skipped_step_event(action, step, res)
  20. return res
  21. end
  22. preconditions = Array(step["preconditions"])
  23. guard = Signals::Decisioning::Execution::PreconditionEvaluator.evaluate_all(preconditions, synced_email: synced_email, step: step)
  24. unless guard[:ok]
  25. res = {
  26. "step_id" => step["step_id"],
  27. "action" => action,
  28. "status" => "skipped_precondition_failed",
  29. "failed_preconditions" => guard[:failed],
  30. "unknown_preconditions" => guard[:unknown]
  31. }
  32. emit_skipped_step_event(action, step, res)
  33. return res
  34. end
  35. handler_class = handler_for(action)
  36. unless handler_class
  37. res = { "step_id" => step["step_id"], "status" => "skipped_unknown_action", "action" => action }
  38. emit_skipped_step_event(action, step, res)
  39. return res
  40. end
  41. handler = handler_class.new(synced_email)
  42. if pipeline_recorder
  43. pipeline_recorder.measure(
  44. :"execute_#{action}",
  45. input_payload: {
  46. "step_id" => step["step_id"],
  47. "action" => action,
  48. "target" => step["target"],
  49. "params" => step["params"]
  50. },
  51. output_payload_override: ->(result) { { "result" => result } }
  52. ) { handler.call(step) }
  53. else
  54. handler.call(step)
  55. end
  56. end
  57. private
  58. attr_reader :synced_email, :pipeline_recorder
  59. def emit_skipped_step_event(action, step, result)
  60. return unless pipeline_recorder
  61. return unless Signals::EmailPipelineEvent.event_types.key?("execute_#{action}")
  62. pipeline_recorder.event!(
  63. event_type: :"execute_#{action}",
  64. status: :skipped,
  65. input_payload: {
  66. "step_id" => step["step_id"],
  67. "action" => action,
  68. "target" => step["target"],
  69. "params" => step["params"],
  70. "preconditions" => step["preconditions"]
  71. },
  72. output_payload: { "result" => result }
  73. )
  74. end
  75. def handler_for(action)
  76. case action
  77. when "set_pipeline_stage" then Signals::Decisioning::Execution::Handlers::SetPipelineStage
  78. when "set_application_status" then Signals::Decisioning::Execution::Handlers::SetApplicationStatus
  79. when "create_round" then Signals::Decisioning::Execution::Handlers::CreateRound
  80. when "update_round" then Signals::Decisioning::Execution::Handlers::UpdateRound
  81. when "set_round_result" then Signals::Decisioning::Execution::Handlers::SetRoundResult
  82. when "create_interview_feedback" then Signals::Decisioning::Execution::Handlers::CreateInterviewFeedback
  83. when "create_company_feedback" then Signals::Decisioning::Execution::Handlers::CreateCompanyFeedback
  84. when "create_opportunity" then Signals::Decisioning::Execution::Handlers::CreateOpportunity
  85. when "upsert_job_listing_from_url" then Signals::Decisioning::Execution::Handlers::UpsertJobListingFromUrl
  86. when "attach_job_listing_to_opportunity" then Signals::Decisioning::Execution::Handlers::AttachJobListingToOpportunity
  87. when "enqueue_scrape_job_listing" then Signals::Decisioning::Execution::Handlers::EnqueueScrapeJobListing
  88. else nil
  89. end
  90. end
  91. def requires_application?(action)
  92. %w[
  93. set_pipeline_stage
  94. set_application_status
  95. create_round
  96. update_round
  97. set_round_result
  98. create_interview_feedback
  99. create_company_feedback
  100. ].include?(action)
  101. end
  102. end
  103. end
  104. end
  105. end

app/domains/signals/services/decisioning/execution/handlers/attach_job_listing_to_opportunity.rb

0.0% lines covered

100.0% branches covered

36 relevant lines. 0 lines covered and 36 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class AttachJobListingToOpportunity < BaseHandler
  7. def call(step)
  8. params = step["params"] || {}
  9. url = params["url"].to_s
  10. return { "action" => "attach_job_listing_to_opportunity", "status" => "no_url" } if url.blank?
  11. opportunity = Opportunity.find_by(synced_email_id: synced_email.id)
  12. return { "action" => "attach_job_listing_to_opportunity", "status" => "no_opportunity" } unless opportunity
  13. job_listing = JobListing.find_by(url: normalize_url(url)) || JobListing.find_by(url: url)
  14. return { "action" => "attach_job_listing_to_opportunity", "status" => "no_job_listing" } unless job_listing
  15. if opportunity.job_listing_id == job_listing.id
  16. return { "action" => "attach_job_listing_to_opportunity", "status" => "already_attached", "opportunity_id" => opportunity.id, "job_listing_id" => job_listing.id }
  17. end
  18. opportunity.update!(job_listing: job_listing)
  19. { "action" => "attach_job_listing_to_opportunity", "opportunity_id" => opportunity.id, "job_listing_id" => job_listing.id }
  20. end
  21. private
  22. def normalize_url(url)
  23. uri = URI.parse(url.strip)
  24. return url.strip unless uri.query.present?
  25. params = URI.decode_www_form(uri.query).reject do |key, _|
  26. %w[utm_source utm_medium utm_campaign utm_content utm_term ref source].include?(key.downcase)
  27. end
  28. uri.query = params.any? ? URI.encode_www_form(params) : nil
  29. uri.to_s
  30. rescue URI::InvalidURIError
  31. url.strip
  32. end
  33. end
  34. end
  35. end
  36. end
  37. end

app/domains/signals/services/decisioning/execution/handlers/base_handler.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class BaseHandler
  7. def initialize(synced_email)
  8. @synced_email = synced_email
  9. end
  10. private
  11. attr_reader :synced_email
  12. def app
  13. synced_email.interview_application
  14. end
  15. def resolve_round(target)
  16. return nil unless app
  17. sel = target.dig("round", "selector")
  18. case sel
  19. when "by_id"
  20. id = target.dig("round", "id")
  21. app.interview_rounds.find_by(id: id)
  22. when "latest_pending"
  23. app.interview_rounds.where(result: :pending).order(scheduled_at: :desc).first
  24. when "latest"
  25. app.interview_rounds.ordered.last
  26. else
  27. nil
  28. end
  29. end
  30. end
  31. end
  32. end
  33. end
  34. end

app/domains/signals/services/decisioning/execution/handlers/create_company_feedback.rb

0.0% lines covered

100.0% branches covered

39 relevant lines. 0 lines covered and 39 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class CreateCompanyFeedback < BaseHandler
  7. def call(step)
  8. return { "action" => "create_company_feedback", "status" => "no_application" } unless app
  9. params = step["params"] || {}
  10. existing = CompanyFeedback.find_by(interview_application_id: app.id)
  11. if existing
  12. # Idempotency: avoid duplicate feedback due to association caching during replays.
  13. if existing.source_email_id == synced_email.id
  14. return {
  15. "action" => "create_company_feedback",
  16. "status" => "already_exists",
  17. "feedback_id" => existing.id
  18. }
  19. end
  20. return {
  21. "action" => "create_company_feedback",
  22. "status" => "already_exists",
  23. "feedback_id" => existing.id
  24. }
  25. end
  26. fb = CompanyFeedback.create!(
  27. interview_application: app,
  28. source_email_id: synced_email.id,
  29. feedback_type: params["feedback_type"],
  30. feedback_text: params["feedback_text"],
  31. rejection_reason: params["rejection_reason"],
  32. next_steps: params["next_steps"],
  33. received_at: synced_email.email_date || Time.current
  34. )
  35. { "action" => "create_company_feedback", "feedback_id" => fb.id }
  36. end
  37. end
  38. end
  39. end
  40. end
  41. end

app/domains/signals/services/decisioning/execution/handlers/create_interview_feedback.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class CreateInterviewFeedback < BaseHandler
  7. def call(step)
  8. params = step["params"] || {}
  9. round = resolve_round(step["target"] || {})
  10. return { "action" => "create_interview_feedback", "status" => "no_round_resolved" } unless round
  11. existing = InterviewFeedback.find_by(interview_round_id: round.id)
  12. if existing
  13. return {
  14. "action" => "create_interview_feedback",
  15. "status" => "already_exists",
  16. "round_id" => round.id,
  17. "feedback_id" => existing.id
  18. }
  19. end
  20. fb = InterviewFeedback.create!(
  21. interview_round: round,
  22. went_well: params["went_well"],
  23. to_improve: params["to_improve"],
  24. ai_summary: params["ai_summary"],
  25. interviewer_notes: params["interviewer_notes"],
  26. recommended_action: params["recommended_action"]
  27. )
  28. { "action" => "create_interview_feedback", "round_id" => round.id, "feedback_id" => fb.id }
  29. end
  30. end
  31. end
  32. end
  33. end
  34. end

app/domains/signals/services/decisioning/execution/handlers/create_opportunity.rb

0.0% lines covered

100.0% branches covered

31 relevant lines. 0 lines covered and 31 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class CreateOpportunity < BaseHandler
  7. def call(step)
  8. params = step["params"] || {}
  9. synced_email_id = params.dig("source", "synced_email_id") || synced_email.id
  10. existing = Opportunity.find_by(synced_email_id: synced_email_id)
  11. if existing
  12. return { "action" => "create_opportunity", "status" => "already_exists", "opportunity_id" => existing.id }
  13. end
  14. opp = Opportunity.create!(
  15. user: synced_email.user,
  16. synced_email_id: synced_email_id,
  17. status: "new",
  18. company_name: params["company_name"],
  19. job_role_title: params["job_title"],
  20. job_url: params["job_url"],
  21. recruiter_name: params["recruiter_name"],
  22. recruiter_email: params["recruiter_email"],
  23. extracted_links: params["extracted_links"] || [],
  24. email_snippet: synced_email.snippet || synced_email.body_preview&.truncate(500)
  25. )
  26. { "action" => "create_opportunity", "opportunity_id" => opp.id }
  27. end
  28. end
  29. end
  30. end
  31. end
  32. end

app/domains/signals/services/decisioning/execution/handlers/create_round.rb

0.0% lines covered

100.0% branches covered

37 relevant lines. 0 lines covered and 37 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class CreateRound < BaseHandler
  7. def call(step)
  8. return { "action" => "create_round", "status" => "no_application" } unless app
  9. params = step["params"] || {}
  10. existing = app.interview_rounds.find_by(
  11. source_email_id: synced_email.id,
  12. stage: params["stage"],
  13. stage_name: params["stage_name"],
  14. scheduled_at: params["scheduled_at"]
  15. )
  16. if existing
  17. return { "action" => "create_round", "status" => "already_exists", "round_id" => existing.id }
  18. end
  19. position = app.interview_rounds.maximum(:position).to_i + 1
  20. round = app.interview_rounds.create!(
  21. stage: params["stage"],
  22. stage_name: params["stage_name"],
  23. scheduled_at: params["scheduled_at"],
  24. duration_minutes: params["duration_minutes"],
  25. interviewer_name: params["interviewer_name"],
  26. interviewer_role: params["interviewer_role"],
  27. video_link: params["video_link"],
  28. position: position,
  29. notes: params["notes"],
  30. source_email_id: synced_email.id
  31. )
  32. { "action" => "create_round", "round_id" => round.id }
  33. end
  34. end
  35. end
  36. end
  37. end
  38. end

app/domains/signals/services/decisioning/execution/handlers/enqueue_scrape_job_listing.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class EnqueueScrapeJobListing < BaseHandler
  7. def call(step)
  8. params = step["params"] || {}
  9. url = params["url"].to_s
  10. force = !!params["force"]
  11. return { "action" => "enqueue_scrape_job_listing", "status" => "no_url" } if url.blank?
  12. job_listing = JobListing.find_by(url: normalize_url(url)) || JobListing.find_by(url: url)
  13. return { "action" => "enqueue_scrape_job_listing", "status" => "no_job_listing" } unless job_listing
  14. if !force && job_listing.scraped?
  15. return { "action" => "enqueue_scrape_job_listing", "status" => "already_scraped", "job_listing_id" => job_listing.id }
  16. end
  17. ScrapeJobListingJob.perform_later(job_listing)
  18. { "action" => "enqueue_scrape_job_listing", "job_listing_id" => job_listing.id }
  19. end
  20. private
  21. def normalize_url(url)
  22. uri = URI.parse(url.strip)
  23. return url.strip unless uri.query.present?
  24. params = URI.decode_www_form(uri.query).reject do |key, _|
  25. %w[utm_source utm_medium utm_campaign utm_content utm_term ref source].include?(key.downcase)
  26. end
  27. uri.query = params.any? ? URI.encode_www_form(params) : nil
  28. uri.to_s
  29. rescue URI::InvalidURIError
  30. url.strip
  31. end
  32. end
  33. end
  34. end
  35. end
  36. end

app/domains/signals/services/decisioning/execution/handlers/set_application_status.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class SetApplicationStatus < BaseHandler
  7. def call(step)
  8. status = step.dig("params", "status")
  9. ctx = Signals::StateContext.new(synced_email)
  10. applier = Signals::ActionApplier.new(ctx)
  11. res = applier.apply!([ { type: :set_application_status, status: status&.to_sym } ])
  12. { "action" => "set_application_status", "status" => status, "result" => res }
  13. end
  14. end
  15. end
  16. end
  17. end
  18. end

app/domains/signals/services/decisioning/execution/handlers/set_pipeline_stage.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class SetPipelineStage < BaseHandler
  7. def call(step)
  8. stage = step.dig("params", "stage")
  9. ctx = Signals::StateContext.new(synced_email)
  10. applier = Signals::ActionApplier.new(ctx)
  11. res = applier.apply!([ { type: :set_pipeline_stage, stage: stage&.to_sym } ])
  12. { "action" => "set_pipeline_stage", "stage" => stage, "result" => res }
  13. end
  14. end
  15. end
  16. end
  17. end
  18. end

app/domains/signals/services/decisioning/execution/handlers/set_round_result.rb

0.0% lines covered

100.0% branches covered

29 relevant lines. 0 lines covered and 29 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class SetRoundResult < BaseHandler
  7. def call(step)
  8. params = step["params"] || {}
  9. round = resolve_round(step["target"] || {})
  10. return { "action" => "set_round_result", "status" => "no_round_resolved" } unless round
  11. if round.result.to_s == params["result"].to_s && round.source_email_id == synced_email.id
  12. return {
  13. "action" => "set_round_result",
  14. "status" => "already_set",
  15. "round_id" => round.id,
  16. "result" => round.result
  17. }
  18. end
  19. round.update!(
  20. result: params["result"],
  21. completed_at: params["completed_at"] || Time.current,
  22. source_email_id: synced_email.id
  23. )
  24. { "action" => "set_round_result", "round_id" => round.id, "result" => round.result }
  25. end
  26. end
  27. end
  28. end
  29. end
  30. end

app/domains/signals/services/decisioning/execution/handlers/update_round.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class UpdateRound < BaseHandler
  7. def call(step)
  8. params = step["params"] || {}
  9. round = resolve_round(step["target"] || {})
  10. return { "action" => "update_round", "status" => "no_round_resolved" } unless round
  11. notes = round.notes.to_s
  12. notes_append = params["notes_append"].to_s
  13. if notes_append.present? && !notes.include?(notes_append)
  14. notes = [ notes, notes_append ].reject(&:blank?).join("\n").presence.to_s
  15. end
  16. round.update!(
  17. scheduled_at: params["scheduled_at"] || round.scheduled_at,
  18. duration_minutes: params["duration_minutes"] || round.duration_minutes,
  19. interviewer_name: params["interviewer_name"] || round.interviewer_name,
  20. interviewer_role: params["interviewer_role"] || round.interviewer_role,
  21. video_link: params["video_link"] || round.video_link,
  22. notes: notes.presence,
  23. source_email_id: synced_email.id
  24. )
  25. { "action" => "update_round", "round_id" => round.id }
  26. end
  27. end
  28. end
  29. end
  30. end
  31. end

app/domains/signals/services/decisioning/execution/handlers/upsert_job_listing_from_url.rb

0.0% lines covered

100.0% branches covered

43 relevant lines. 0 lines covered and 43 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. module Handlers
  6. class UpsertJobListingFromUrl < BaseHandler
  7. def call(step)
  8. params = step["params"] || {}
  9. url = params["url"].to_s
  10. return { "action" => "upsert_job_listing_from_url", "status" => "no_url" } if url.blank?
  11. company = find_or_create_company(params["company_name"])
  12. job_role = find_or_create_job_role(params["job_role_title"] || params["job_title"])
  13. res = JobListings::UpsertFromUrlService.new(
  14. url: url,
  15. company: company,
  16. job_role: job_role,
  17. title: params["job_title"].presence || job_role.title
  18. ).call
  19. {
  20. "action" => "upsert_job_listing_from_url",
  21. "status" => (res[:created] ? "created" : "already_exists"),
  22. "job_listing_id" => res[:job_listing].id
  23. }
  24. end
  25. private
  26. def find_or_create_company(name)
  27. normalized = name.to_s.strip
  28. normalized = "Unknown Company" if normalized.blank?
  29. existing = Company.find_by("LOWER(name) = ?", normalized.downcase)
  30. return existing if existing
  31. Company.create!(name: normalized.titleize)
  32. end
  33. def find_or_create_job_role(title)
  34. t = title.to_s.strip
  35. t = "Unknown Position" if t.blank?
  36. existing = JobRole.find_by("LOWER(title) = ?", t.downcase)
  37. return existing if existing
  38. JobRole.create!(title: t)
  39. end
  40. end
  41. end
  42. end
  43. end
  44. end

app/domains/signals/services/decisioning/execution/precondition_evaluator.rb

0.0% lines covered

100.0% branches covered

78 relevant lines. 0 lines covered and 78 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Execution
  5. # Fail-closed evaluator for plan preconditions.
  6. #
  7. # IMPORTANT:
  8. # - This does NOT eval Ruby.
  9. # - Only known predicates are supported.
  10. # - Unknown predicates fail closed (skip step).
  11. class PreconditionEvaluator
  12. class << self
  13. def evaluate_all(preconditions, synced_email:, step:)
  14. preds = Array(preconditions).map(&:to_s).map(&:strip).reject(&:blank?)
  15. return { ok: true, failed: [], unknown: [] } if preds.empty?
  16. failed = []
  17. unknown = []
  18. preds.each do |pred|
  19. res = evaluate(pred, synced_email: synced_email, step: step)
  20. if res == :unknown
  21. unknown << pred
  22. failed << pred
  23. elsif res == false
  24. failed << pred
  25. end
  26. end
  27. { ok: failed.empty?, failed: failed, unknown: unknown }
  28. end
  29. def evaluate(predicate, synced_email:, step:)
  30. pred = predicate.to_s.strip
  31. return synced_email.matched? if pred == "match.matched == true"
  32. if (m = pred.match(/\Aapplication\.pipeline_stage != (\w+)\z/))
  33. app = synced_email.interview_application
  34. return false unless app
  35. return app.pipeline_stage.to_s != m[1]
  36. end
  37. if (m = pred.match(/\Aapplication\.pipeline_stage == (\w+)\z/))
  38. app = synced_email.interview_application
  39. return false unless app
  40. return app.pipeline_stage.to_s == m[1]
  41. end
  42. if (m = pred.match(/\Aapplication\.status == (\w+)\z/))
  43. app = synced_email.interview_application
  44. return false unless app
  45. return app.status.to_s == m[1]
  46. end
  47. if pred == "application.company_feedback == null"
  48. app = synced_email.interview_application
  49. return false unless app
  50. return app.company_feedback.nil?
  51. end
  52. if pred == "application.rounds_recent.any(result==pending) == true"
  53. app = synced_email.interview_application
  54. return false unless app
  55. return app.interview_rounds.where(result: :pending).exists?
  56. end
  57. if pred == "round.interview_feedback == null"
  58. round = resolve_round(synced_email, step["target"] || {})
  59. return false unless round
  60. return round.interview_feedback.nil?
  61. end
  62. :unknown
  63. end
  64. private
  65. def resolve_round(synced_email, target)
  66. app = synced_email.interview_application
  67. return nil unless app
  68. sel = target.dig("round", "selector")
  69. case sel
  70. when "by_id"
  71. id = target.dig("round", "id")
  72. app.interview_rounds.find_by(id: id)
  73. when "latest_pending"
  74. app.interview_rounds.where(result: :pending).order(scheduled_at: :desc).first
  75. when "latest"
  76. app.interview_rounds.ordered.last
  77. else
  78. nil
  79. end
  80. end
  81. end
  82. end
  83. end
  84. end
  85. end

app/domains/signals/services/decisioning/execution_runner.rb

0.0% lines covered

100.0% branches covered

163 relevant lines. 0 lines covered and 163 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. # Executes a DecisionPlan with guardrails.
  5. #
  6. # This is intentionally conservative. If anything is invalid, it fails closed.
  7. class ExecutionRunner < ApplicationService
  8. DECISION_INPUT_SCHEMA_ID = "gleania://signals/contracts/schemas/decision_input.schema.json"
  9. DECISION_PLAN_SCHEMA_ID = "gleania://signals/contracts/schemas/decision_plan.schema.json"
  10. EMAIL_FACTS_SCHEMA_ID = "gleania://signals/contracts/schemas/components/facts/email_facts.schema.json"
  11. EXECUTION_META_KEY = "decision_execution_v1"
  12. def initialize(synced_email, pipeline_run: nil)
  13. @synced_email = synced_email
  14. @pipeline_recorder = Signals::Observability::EmailPipelineRecorder.for_run(pipeline_run)
  15. end
  16. def call
  17. return false unless Setting.signals_decision_execution_enabled?
  18. log_info("Execution start: synced_email_id=#{synced_email.id} matched=#{synced_email.matched?}")
  19. builder = Signals::Decisioning::DecisionInputBuilder.new(synced_email)
  20. base = builder.build_base
  21. facts = facts_for_execution(builder, base)
  22. decision_input =
  23. if pipeline_recorder
  24. pipeline_recorder.measure(
  25. :decision_input_build,
  26. input_payload: { "synced_email_id" => synced_email.id },
  27. output_payload_override: { "matched" => synced_email.matched? }
  28. ) { builder.build(facts: facts) }
  29. else
  30. builder.build(facts: facts)
  31. end
  32. unless schema_valid?(DECISION_INPUT_SCHEMA_ID, decision_input)
  33. errors = schema_errors(DECISION_INPUT_SCHEMA_ID, decision_input)
  34. log_warning("Execution invalid DecisionInput: synced_email_id=#{synced_email.id} errors=#{errors.size}")
  35. pipeline_recorder&.event!(
  36. event_type: :decision_plan_schema_validate,
  37. status: :failed,
  38. output_payload: { "decision_input_error_count" => errors.size }
  39. )
  40. return persist(status: "decision_input_invalid", errors: errors)
  41. end
  42. decision_plan =
  43. if pipeline_recorder
  44. pipeline_recorder.measure(
  45. :decision_plan_build,
  46. input_payload: { "synced_email_id" => synced_email.id },
  47. output_payload_override: {}
  48. ) do
  49. Signals::Decisioning::Planner.new(decision_input).plan
  50. end
  51. else
  52. Signals::Decisioning::Planner.new(decision_input).plan
  53. end
  54. unless schema_valid?(DECISION_PLAN_SCHEMA_ID, decision_plan)
  55. errors = schema_errors(DECISION_PLAN_SCHEMA_ID, decision_plan)
  56. log_warning("Execution invalid DecisionPlan: synced_email_id=#{synced_email.id} errors=#{errors.size}")
  57. pipeline_recorder&.event!(
  58. event_type: :decision_plan_schema_validate,
  59. status: :failed,
  60. output_payload: { "decision_plan_error_count" => errors.size }
  61. )
  62. return persist(status: "decision_plan_invalid", errors: errors)
  63. end
  64. pipeline_recorder&.event!(
  65. event_type: :decision_plan_schema_validate,
  66. status: :success,
  67. output_payload: { "decision" => decision_plan["decision"], "steps" => decision_plan.fetch("plan", []).size }
  68. )
  69. semantic_errors =
  70. if pipeline_recorder
  71. pipeline_recorder.measure(
  72. :decision_plan_semantic_validate,
  73. input_payload: { "synced_email_id" => synced_email.id },
  74. output_payload_override: lambda { |errs| { "error_count" => Array(errs).size } }
  75. ) { Signals::Decisioning::SemanticValidator.new(decision_input, decision_plan).errors }
  76. else
  77. Signals::Decisioning::SemanticValidator.new(decision_input, decision_plan).errors
  78. end
  79. if semantic_errors.any?
  80. log_warning("Execution semantic invalid: synced_email_id=#{synced_email.id} errors=#{semantic_errors.size}")
  81. return persist(status: "semantic_invalid", errors: semantic_errors)
  82. end
  83. applied =
  84. if pipeline_recorder
  85. pipeline_recorder.measure(
  86. :execution_dispatch,
  87. input_payload: { "synced_email_id" => synced_email.id },
  88. output_payload_override: {}
  89. ) { execute_steps(decision_plan) }
  90. else
  91. execute_steps(decision_plan)
  92. end
  93. log_info("Execution executed: synced_email_id=#{synced_email.id} applied_steps=#{applied.size}")
  94. persist(status: "executed", errors: [], applied: applied)
  95. rescue StandardError => e
  96. notify_error(
  97. e,
  98. context: "signals_decision_execution_runner",
  99. severity: "error",
  100. user: synced_email&.user,
  101. synced_email_id: synced_email&.id,
  102. application_id: synced_email&.interview_application_id
  103. )
  104. log_error("Execution exception: synced_email_id=#{synced_email&.id} #{e.class}: #{e.message}")
  105. persist(status: "exception", errors: [ { "message" => e.message, "class" => e.class.name } ])
  106. end
  107. private
  108. attr_reader :synced_email, :pipeline_recorder
  109. def facts_for_execution(builder, base)
  110. app = synced_email.interview_application
  111. unless Setting.signals_email_facts_extraction_enabled?
  112. return builder.build_fallback_facts(app)
  113. end
  114. persisted = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data[Signals::Facts::EmailFactsExtractor::FACTS_KEY] : nil
  115. persisted_meta = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data[Signals::Facts::EmailFactsExtractor::FACTS_META_KEY] : nil
  116. if persisted.is_a?(Hash) && persisted_meta.is_a?(Hash) && persisted_meta["status"] == "ok" && schema_valid?(EMAIL_FACTS_SCHEMA_ID, persisted)
  117. pipeline_recorder&.event!(
  118. event_type: :email_facts_extraction,
  119. status: :success,
  120. output_payload: { "source" => "persisted", "kind" => persisted.dig("classification", "kind") }.compact
  121. )
  122. return persisted
  123. end
  124. extractor = Signals::Facts::EmailFactsExtractor.new(synced_email, decision_input_base: base)
  125. res =
  126. if pipeline_recorder
  127. pipeline_recorder.measure(
  128. :email_facts_extraction,
  129. input_payload: { "synced_email_id" => synced_email.id },
  130. output_payload_override: lambda { |r|
  131. {
  132. "success" => r[:success],
  133. "llm_api_log_id" => r[:llm_api_log_id],
  134. "kind" => r.dig(:facts, "classification", "kind"),
  135. "error" => r[:error]
  136. }.compact
  137. }
  138. ) { extractor.call }
  139. else
  140. extractor.call
  141. end
  142. res[:success] ? res[:facts] : builder.build_fallback_facts(app)
  143. end
  144. def schema_valid?(schema_id, payload)
  145. schema_errors(schema_id, payload).empty?
  146. end
  147. def schema_errors(schema_id, payload)
  148. Signals::Contracts::Validators::JsonSchemaValidator.new(schema_id: schema_id).errors_for(payload)
  149. end
  150. def execute_steps(plan)
  151. dispatcher = Signals::Decisioning::Execution::Dispatcher.new(synced_email, pipeline_recorder: pipeline_recorder)
  152. plan.fetch("plan", []).filter_map { |step| dispatcher.dispatch(step) }
  153. end
  154. def persist(status:, errors:, applied: nil)
  155. existing = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data.deep_dup : {}
  156. existing[EXECUTION_META_KEY] = {
  157. "status" => status,
  158. "errors" => errors,
  159. "applied" => applied,
  160. "executed_at" => Time.current.iso8601
  161. }
  162. synced_email.update!(extracted_data: existing)
  163. status == "executed"
  164. end
  165. end
  166. end
  167. end

app/domains/signals/services/decisioning/planner.rb

0.0% lines covered

100.0% branches covered

48 relevant lines. 0 lines covered and 48 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. # Deterministic baseline planner that drives decisions from EmailFacts.
  5. #
  6. # This planner should not rely on legacy regex classification directly; it
  7. # should rely on `DecisionInput.facts` and the evidence strings within it.
  8. class Planner
  9. VERSION = "2026-01-27".freeze
  10. RULES = [
  11. Signals::Decisioning::Rules::OpportunityRule,
  12. Signals::Decisioning::Rules::SchedulingRule,
  13. Signals::Decisioning::Rules::RoundFeedbackRule,
  14. Signals::Decisioning::Rules::StatusUpdateRule
  15. ].freeze
  16. def initialize(decision_input)
  17. @input = decision_input
  18. end
  19. def plan
  20. RULES.each do |rule_class|
  21. res = rule_class.new(input).call
  22. next if res.nil?
  23. if res[:decision] == "noop"
  24. return noop(res[:reason])
  25. end
  26. return apply(confidence: res[:confidence], reasons: res[:reasons], steps: res[:steps])
  27. end
  28. return noop("unmatched_email") unless input.dig("match", "matched")
  29. noop("unsupported_kind")
  30. end
  31. private
  32. attr_reader :input
  33. def noop(reason)
  34. {
  35. "version" => VERSION,
  36. "decision" => "noop",
  37. "confidence" => 1.0,
  38. "reasons" => [ reason ],
  39. "plan" => []
  40. }
  41. end
  42. def apply(confidence:, reasons:, steps:)
  43. {
  44. "version" => VERSION,
  45. "decision" => "apply",
  46. "confidence" => confidence,
  47. "reasons" => reasons,
  48. "plan" => steps
  49. }
  50. end
  51. end
  52. end
  53. end

app/domains/signals/services/decisioning/rules/base_rule.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Rules
  5. class BaseRule
  6. def initialize(input)
  7. @input = input
  8. end
  9. private
  10. attr_reader :input
  11. def kind
  12. input.dig("facts", "classification", "kind").to_s
  13. end
  14. def matched?
  15. !!input.dig("match", "matched")
  16. end
  17. def app_id
  18. input.dig("application", "id")
  19. end
  20. def email_id
  21. input.dig("event", "synced_email_id")
  22. end
  23. def email_date
  24. input.dig("event", "email_date")
  25. end
  26. def step_factory
  27. @step_factory ||= Signals::Decisioning::StepFactory.new(
  28. application_id: app_id,
  29. synced_email_id: email_id,
  30. email_date: email_date
  31. )
  32. end
  33. end
  34. end
  35. end
  36. end

app/domains/signals/services/decisioning/rules/opportunity_rule.rb

0.0% lines covered

100.0% branches covered

113 relevant lines. 0 lines covered and 113 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Rules
  5. class OpportunityRule < BaseRule
  6. MIN_CLASSIFICATION_CONFIDENCE = 0.7
  7. def call
  8. return nil if matched?
  9. k = kind
  10. return nil unless %w[recruiter_outreach potential_opportunity].include?(k)
  11. classification_conf = input.dig("facts", "classification", "confidence").to_f
  12. return { decision: "noop", reason: "opportunity_low_confidence" } if classification_conf < MIN_CLASSIFICATION_CONFIDENCE
  13. evidence = Array(input.dig("facts", "classification", "evidence")).first(3)
  14. evidence = Array(input.dig("facts", "action_links")).first(1).map { |l| "job posting: #{l["url"]}" } if evidence.empty?
  15. return { decision: "noop", reason: "opportunity_no_evidence" } if evidence.empty?
  16. company_name = input.dig("facts", "entities", "company", "name")
  17. recruiter_name = input.dig("facts", "entities", "recruiter", "name") || input.dig("event", "from", "name")
  18. recruiter_email = input.dig("facts", "entities", "recruiter", "email") || input.dig("event", "from", "email")
  19. job_title = input.dig("facts", "entities", "job", "title")
  20. job_url = choose_job_url
  21. extracted_links = build_extracted_links(job_url)
  22. steps = [
  23. {
  24. "step_id" => "create_opportunity",
  25. "action" => "create_opportunity",
  26. "target" => step_factory.target(selector: "none").merge("application_id" => nil),
  27. "params" => {
  28. "company_name" => company_name,
  29. "job_title" => job_title,
  30. "job_url" => job_url,
  31. "recruiter_name" => recruiter_name,
  32. "recruiter_email" => recruiter_email,
  33. "extracted_links" => extracted_links,
  34. "source" => { "synced_email_id" => email_id }
  35. },
  36. "preconditions" => [],
  37. "evidence" => evidence,
  38. "risk" => "low"
  39. }
  40. ]
  41. if job_url.present?
  42. steps.concat(job_listing_steps(job_url, company_name, job_title))
  43. end
  44. { decision: "apply", confidence: 0.75, reasons: [ "recruiter_outreach_unmatched" ], steps: steps }
  45. end
  46. private
  47. def choose_job_url
  48. input.dig("facts", "entities", "job", "url") ||
  49. Array(input.dig("facts", "action_links")).map { |l| l["url"] }.find(&:present?) ||
  50. Array(input.dig("event", "links")).map { |l| l["url"] }.find(&:present?)
  51. end
  52. def build_extracted_links(job_url)
  53. links = Array(input.dig("event", "links")).map do |l|
  54. {
  55. "url" => l["url"].to_s,
  56. "type" => (l["url"].to_s == job_url.to_s ? "job_posting" : "unknown"),
  57. "description" => l["label_hint"]
  58. }
  59. end
  60. if links.empty? && job_url.present?
  61. links = [
  62. { "url" => job_url.to_s, "type" => "job_posting", "description" => "Job posting" }
  63. ]
  64. end
  65. links.first(50)
  66. end
  67. def job_listing_steps(job_url, company_name, job_title)
  68. [
  69. {
  70. "step_id" => "upsert_job_listing_from_url",
  71. "action" => "upsert_job_listing_from_url",
  72. "target" => step_factory.target(selector: "none").merge("application_id" => nil),
  73. "params" => {
  74. "url" => job_url,
  75. "company_name" => company_name,
  76. "job_role_title" => job_title,
  77. "job_title" => job_title,
  78. "source" => { "synced_email_id" => email_id }
  79. },
  80. "preconditions" => [],
  81. "evidence" => [ "job posting: #{job_url}" ],
  82. "risk" => "low"
  83. },
  84. {
  85. "step_id" => "attach_job_listing_to_opportunity",
  86. "action" => "attach_job_listing_to_opportunity",
  87. "target" => step_factory.target(selector: "none").merge("application_id" => nil),
  88. "params" => {
  89. "url" => job_url,
  90. "source" => { "synced_email_id" => email_id }
  91. },
  92. "preconditions" => [],
  93. "evidence" => [ "job posting: #{job_url}" ],
  94. "risk" => "low"
  95. },
  96. {
  97. "step_id" => "enqueue_scrape_job_listing",
  98. "action" => "enqueue_scrape_job_listing",
  99. "target" => step_factory.target(selector: "none").merge("application_id" => nil),
  100. "params" => {
  101. "url" => job_url,
  102. "force" => false,
  103. "source" => { "synced_email_id" => email_id }
  104. },
  105. "preconditions" => [],
  106. "evidence" => [ "job posting: #{job_url}" ],
  107. "risk" => "low"
  108. }
  109. ]
  110. end
  111. end
  112. end
  113. end
  114. end

app/domains/signals/services/decisioning/rules/round_feedback_rule.rb

0.0% lines covered

100.0% branches covered

65 relevant lines. 0 lines covered and 65 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Rules
  5. class RoundFeedbackRule < BaseRule
  6. def call
  7. return nil unless matched?
  8. return nil unless kind == "round_feedback"
  9. rf = input.dig("facts", "round_feedback") || {}
  10. result = rf["result"].to_s
  11. evidence = Array(rf["evidence"]).first(3)
  12. return { decision: "noop", reason: "round_feedback_no_evidence" } if evidence.empty?
  13. mapped = %w[passed failed waitlisted cancelled].include?(result) ? result : nil
  14. return { decision: "noop", reason: "round_feedback_unknown_result" } unless mapped
  15. steps = [
  16. step_factory.set_round_result(
  17. step_id: "set_round_result",
  18. selector: "latest_pending",
  19. result: mapped,
  20. completed_at: input.dig("event", "email_date"),
  21. preconditions: [ "application.rounds_recent.any(result==pending) == true" ],
  22. evidence: evidence,
  23. risk: mapped == "failed" ? "high" : "low"
  24. )
  25. ]
  26. if rf.dig("feedback", "has_detailed_feedback")
  27. fb_text = rf.dig("feedback", "full_feedback_text").to_s
  28. fb_evidence = fb_text.present? ? [ fb_text ] : evidence.first(1)
  29. steps << step_factory.create_interview_feedback(
  30. step_id: "create_interview_feedback",
  31. selector: "latest_pending",
  32. params: {
  33. "went_well" => Array(rf.dig("feedback", "strengths")).map { |s| "• #{s}" }.join("\n").presence,
  34. "to_improve" => Array(rf.dig("feedback", "improvements")).map { |s| "• #{s}" }.join("\n").presence,
  35. "ai_summary" => rf.dig("feedback", "summary"),
  36. "interviewer_notes" => rf.dig("feedback", "full_feedback_text"),
  37. "recommended_action" => default_recommended_action(mapped, rf)
  38. },
  39. preconditions: [ "round.interview_feedback == null" ],
  40. evidence: fb_evidence,
  41. risk: "low"
  42. )
  43. end
  44. { decision: "apply", confidence: 0.7, reasons: [ "round_feedback_kind" ], steps: steps }
  45. end
  46. private
  47. def default_recommended_action(result, round_feedback)
  48. case result
  49. when "passed"
  50. if round_feedback.dig("next_steps", "has_next_round")
  51. "Prepare for #{round_feedback.dig("next_steps", "next_round_type") || "next round"}"
  52. else
  53. "Follow up on next steps"
  54. end
  55. when "failed"
  56. "Review feedback and apply learnings to future interviews"
  57. when "waitlisted"
  58. "Follow up in 1-2 weeks if no update"
  59. else
  60. nil
  61. end
  62. end
  63. end
  64. end
  65. end
  66. end

app/domains/signals/services/decisioning/rules/scheduling_rule.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Rules
  5. class SchedulingRule < BaseRule
  6. def call
  7. return nil unless matched?
  8. return nil unless kind == "scheduling"
  9. scheduling = input.dig("facts", "scheduling") || {}
  10. evidence = Array(scheduling["evidence"]).first(3)
  11. return { decision: "noop", reason: "scheduling_no_evidence" } if evidence.empty?
  12. stage = scheduling["stage"] || "screening"
  13. steps = [
  14. step_factory.create_round(
  15. step_id: "create_round_1",
  16. params: {
  17. "stage" => stage,
  18. "stage_name" => scheduling["stage_name"],
  19. "scheduled_at" => scheduling["scheduled_at"],
  20. "duration_minutes" => scheduling["duration_minutes"].to_i.nonzero? || 30,
  21. "interviewer_name" => scheduling["interviewer_name"],
  22. "interviewer_role" => scheduling["interviewer_role"],
  23. "video_link" => scheduling["video_link"],
  24. "location" => scheduling["location"],
  25. "phone_number" => scheduling["phone_number"],
  26. "notes" => "📬 Created from email signal"
  27. },
  28. preconditions: [ "match.matched == true" ],
  29. evidence: evidence,
  30. risk: "low"
  31. ),
  32. step_factory.set_pipeline_stage(
  33. step_id: "set_pipeline_from_round",
  34. selector: "latest",
  35. stage: (stage == "screening" ? "screening" : "interviewing"),
  36. preconditions: [ "application.pipeline_stage != closed" ],
  37. evidence: evidence.first(1),
  38. risk: "low"
  39. )
  40. ]
  41. { decision: "apply", confidence: 0.6, reasons: [ "scheduling_kind" ], steps: steps }
  42. end
  43. end
  44. end
  45. end
  46. end

app/domains/signals/services/decisioning/rules/status_update_rule.rb

0.0% lines covered

100.0% branches covered

96 relevant lines. 0 lines covered and 96 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. module Rules
  5. class StatusUpdateRule < BaseRule
  6. def call
  7. return nil unless matched?
  8. return nil unless kind == "status_update"
  9. status_change = input.dig("facts", "status_change") || {}
  10. type = status_change["type"].to_s
  11. evidence = Array(status_change["evidence"]).first(3)
  12. return { decision: "noop", reason: "status_update_no_evidence" } if evidence.empty?
  13. case type
  14. when "rejection"
  15. steps = [
  16. step_factory.set_application_status(
  17. step_id: "set_status_rejected",
  18. status: "rejected",
  19. preconditions: [ "application.status == active" ],
  20. evidence: evidence,
  21. risk: "high"
  22. ),
  23. step_factory.set_pipeline_stage(
  24. step_id: "set_pipeline_closed",
  25. selector: "none",
  26. stage: "closed",
  27. preconditions: [ "application.pipeline_stage != closed" ],
  28. evidence: evidence.first(1),
  29. risk: "high"
  30. )
  31. ]
  32. { decision: "apply", confidence: 0.75, reasons: [ "rejection_status_change" ], steps: steps }
  33. when "offer"
  34. steps = [
  35. step_factory.set_pipeline_stage(
  36. step_id: "set_pipeline_offer",
  37. selector: "none",
  38. stage: "offer",
  39. preconditions: [ "application.pipeline_stage != offer" ],
  40. evidence: evidence,
  41. risk: "medium"
  42. ),
  43. step_factory.create_company_feedback(
  44. step_id: "create_company_feedback_offer",
  45. params: {
  46. "feedback_type" => "offer",
  47. "feedback_text" => build_offer_feedback_text(status_change),
  48. "rejection_reason" => nil,
  49. "next_steps" => status_change.dig("offer_details", "next_steps")
  50. },
  51. preconditions: [ "application.company_feedback == null" ],
  52. evidence: evidence.first(2),
  53. risk: "low"
  54. )
  55. ]
  56. { decision: "apply", confidence: 0.7, reasons: [ "offer_status_change" ], steps: steps }
  57. when "on_hold"
  58. steps = [
  59. step_factory.set_application_status(
  60. step_id: "set_status_on_hold",
  61. status: "on_hold",
  62. preconditions: [ "application.status == active" ],
  63. evidence: evidence,
  64. risk: "medium"
  65. )
  66. ]
  67. { decision: "apply", confidence: 0.65, reasons: [ "on_hold_status_change" ], steps: steps }
  68. when "withdrawal"
  69. steps = [
  70. step_factory.set_application_status(
  71. step_id: "set_status_withdrawn",
  72. status: "withdrawn",
  73. preconditions: [ "application.status == active" ],
  74. evidence: evidence,
  75. risk: "medium"
  76. )
  77. ]
  78. { decision: "apply", confidence: 0.65, reasons: [ "withdrawal_status_change" ], steps: steps }
  79. else
  80. { decision: "noop", reason: "status_update_no_change" }
  81. end
  82. end
  83. private
  84. def build_offer_feedback_text(status_change)
  85. role = status_change.dig("offer_details", "role_title")
  86. deadline = status_change.dig("offer_details", "response_deadline")
  87. start = status_change.dig("offer_details", "start_date")
  88. parts = []
  89. parts << "Offer received#{role ? " for #{role}" : ""}."
  90. parts << "Respond by: #{deadline}" if deadline.present?
  91. parts << "Start date: #{start}" if start.present?
  92. parts.join("\n")
  93. end
  94. end
  95. end
  96. end
  97. end

app/domains/signals/services/decisioning/semantic_validator.rb

0.0% lines covered

100.0% branches covered

61 relevant lines. 0 lines covered and 61 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. # Semantic (non-schema) validation for DecisionPlan.
  5. #
  6. # These checks are used as guardrails before execution.
  7. class SemanticValidator
  8. def initialize(decision_input, decision_plan)
  9. @input = decision_input
  10. @plan = decision_plan
  11. end
  12. # @return [Array<Hash>] list of error hashes
  13. def errors
  14. errs = []
  15. body = input.dig("event", "body", "text").to_s
  16. body_norm = normalize_text(body)
  17. body_alnum = normalize_alnum(body)
  18. plan.fetch("plan", []).each do |step|
  19. evidence = Array(step["evidence"])
  20. next if step["action"] == "noop"
  21. if evidence.empty?
  22. errs << { "type" => "missing_evidence", "step_id" => step["step_id"] }
  23. next
  24. end
  25. evidence.each do |ev|
  26. next if ev.to_s.strip.empty?
  27. ev_text = ev.to_s
  28. urls = extract_urls(ev_text)
  29. if urls.any?
  30. urls.each do |url|
  31. next if url.strip.empty?
  32. # Allow trailing punctuation/brackets and minor formatting differences.
  33. unless body.match?(/#{Regexp.escape(url)}[\]\)\}\.,:;!?"]?/i)
  34. errs << { "type" => "evidence_not_in_body", "step_id" => step["step_id"], "evidence" => ev_text }
  35. break
  36. end
  37. end
  38. next
  39. end
  40. ev_norm = normalize_text(ev_text)
  41. ev_alnum = normalize_alnum(ev_text)
  42. unless body_norm.include?(ev_norm) || body_alnum.include?(ev_alnum)
  43. errs << { "type" => "evidence_not_in_body", "step_id" => step["step_id"], "evidence" => ev_text }
  44. end
  45. end
  46. end
  47. errs
  48. end
  49. def valid?
  50. errors.empty?
  51. end
  52. private
  53. attr_reader :input, :plan
  54. def normalize_text(text)
  55. text.to_s.downcase.gsub(/\s+/, " ").strip
  56. end
  57. def normalize_alnum(text)
  58. text.to_s.downcase.gsub(/[^a-z0-9]+/, " ").gsub(/\s+/, " ").strip
  59. end
  60. def extract_urls(text)
  61. URI.extract(text.to_s, %w[http https])
  62. rescue StandardError
  63. []
  64. end
  65. end
  66. end
  67. end

app/domains/signals/services/decisioning/shadow_runner.rb

0.0% lines covered

100.0% branches covered

126 relevant lines. 0 lines covered and 126 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. # Shadow mode runner for the new Facts → Decision contracts.
  5. #
  6. # This does NOT execute any plan. It only:
  7. # - builds DecisionInput
  8. # - generates a DecisionPlan (currently deterministic baseline)
  9. # - validates both shapes against schemas
  10. # - persists them onto SyncedEmail.extracted_data under versioned keys
  11. class ShadowRunner < ApplicationService
  12. DECISION_INPUT_SCHEMA_ID = "gleania://signals/contracts/schemas/decision_input.schema.json"
  13. DECISION_PLAN_SCHEMA_ID = "gleania://signals/contracts/schemas/decision_plan.schema.json"
  14. DECISION_INPUT_KEY = "decision_input_v1"
  15. DECISION_PLAN_KEY = "decision_plan_v1"
  16. DECISION_META_KEY = "decisioning_meta_v1"
  17. def initialize(synced_email, pipeline_run: nil)
  18. @synced_email = synced_email
  19. @pipeline_recorder = Signals::Observability::EmailPipelineRecorder.for_run(pipeline_run)
  20. end
  21. def call
  22. log_info("Shadow decisioning start: synced_email_id=#{synced_email.id} matched=#{synced_email.matched?}")
  23. builder = Signals::Decisioning::DecisionInputBuilder.new(synced_email)
  24. base = builder.build_base
  25. facts =
  26. if Setting.signals_email_facts_extraction_enabled?
  27. extractor = Signals::Facts::EmailFactsExtractor.new(synced_email, decision_input_base: base)
  28. res =
  29. if pipeline_recorder
  30. pipeline_recorder.measure(
  31. :email_facts_extraction,
  32. input_payload: { "synced_email_id" => synced_email.id },
  33. output_payload_override: lambda { |r|
  34. {
  35. "success" => r[:success],
  36. "llm_api_log_id" => r[:llm_api_log_id],
  37. "kind" => r.dig(:facts, "classification", "kind"),
  38. "error" => r[:error]
  39. }.compact
  40. }
  41. ) { extractor.call }
  42. else
  43. extractor.call
  44. end
  45. res[:success] ? res[:facts] : builder.build_fallback_facts(synced_email.interview_application)
  46. else
  47. builder.build_fallback_facts(synced_email.interview_application)
  48. end
  49. decision_input =
  50. if pipeline_recorder
  51. pipeline_recorder.measure(
  52. :decision_input_build,
  53. input_payload: { "synced_email_id" => synced_email.id },
  54. output_payload_override: { "matched" => synced_email.matched? }
  55. ) { builder.build(facts: facts) }
  56. else
  57. builder.build(facts: facts)
  58. end
  59. input_errors = schema_validator(DECISION_INPUT_SCHEMA_ID).errors_for(decision_input)
  60. if input_errors.any?
  61. log_warning("Shadow decisioning invalid DecisionInput: synced_email_id=#{synced_email.id} errors=#{input_errors.size}")
  62. pipeline_recorder&.event!(
  63. event_type: :decision_plan_schema_validate,
  64. status: :failed,
  65. output_payload: { "decision_input_error_count" => input_errors.size }
  66. )
  67. return persist_errors("decision_input_invalid", input_errors)
  68. end
  69. decision_plan =
  70. if pipeline_recorder
  71. pipeline_recorder.measure(
  72. :decision_plan_build,
  73. input_payload: { "synced_email_id" => synced_email.id },
  74. output_payload_override: {}
  75. ) do
  76. Signals::Decisioning::Planner.new(decision_input).plan
  77. end
  78. else
  79. Signals::Decisioning::Planner.new(decision_input).plan
  80. end
  81. plan_errors = schema_validator(DECISION_PLAN_SCHEMA_ID).errors_for(decision_plan)
  82. if plan_errors.any?
  83. log_warning("Shadow decisioning invalid DecisionPlan: synced_email_id=#{synced_email.id} errors=#{plan_errors.size}")
  84. pipeline_recorder&.event!(
  85. event_type: :decision_plan_schema_validate,
  86. status: :failed,
  87. output_payload: { "decision_plan_error_count" => plan_errors.size }
  88. )
  89. return persist_errors("decision_plan_invalid", plan_errors)
  90. end
  91. pipeline_recorder&.event!(
  92. event_type: :decision_plan_schema_validate,
  93. status: :success,
  94. output_payload: { "decision" => decision_plan["decision"], "steps" => decision_plan.fetch("plan", []).size }
  95. )
  96. persist_payloads(decision_input, decision_plan, status: "ok", errors: [])
  97. log_info("Shadow decisioning ok: synced_email_id=#{synced_email.id} decision=#{decision_plan["decision"]}")
  98. rescue StandardError => e
  99. notify_error(
  100. e,
  101. context: "signals_decision_shadow_runner",
  102. severity: "warning",
  103. user: synced_email&.user,
  104. synced_email_id: synced_email&.id,
  105. application_id: synced_email&.interview_application_id
  106. )
  107. log_error("Shadow decisioning exception: synced_email_id=#{synced_email&.id} #{e.class}: #{e.message}")
  108. persist_errors("exception", [ { "message" => e.message, "class" => e.class.name } ])
  109. end
  110. private
  111. attr_reader :synced_email, :pipeline_recorder
  112. def schema_validator(schema_id)
  113. Signals::Contracts::Validators::JsonSchemaValidator.new(schema_id: schema_id)
  114. end
  115. def persist_payloads(decision_input, decision_plan, status:, errors:)
  116. existing = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data.deep_dup : {}
  117. now = Time.current.iso8601
  118. existing[DECISION_INPUT_KEY] = decision_input
  119. existing[DECISION_PLAN_KEY] = decision_plan
  120. existing[DECISION_META_KEY] = {
  121. "status" => status,
  122. "errors" => errors,
  123. "generated_at" => now
  124. }
  125. synced_email.update!(extracted_data: existing)
  126. true
  127. end
  128. def persist_errors(status, errors)
  129. persist_payloads(nil, nil, status: status, errors: errors)
  130. false
  131. end
  132. end
  133. end
  134. end

app/domains/signals/services/decisioning/step_factory.rb

0.0% lines covered

100.0% branches covered

109 relevant lines. 0 lines covered and 109 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Decisioning
  4. # Helper for consistent DecisionPlan step construction.
  5. class StepFactory
  6. def initialize(application_id:, synced_email_id:, email_date: nil)
  7. @application_id = application_id
  8. @synced_email_id = synced_email_id
  9. @email_date = email_date
  10. end
  11. def target(selector:)
  12. {
  13. "application_id" => application_id,
  14. "round" => {
  15. "selector" => selector,
  16. "id" => nil,
  17. "scheduled_at" => nil,
  18. "window_minutes" => 0,
  19. "stage" => nil,
  20. "result" => nil
  21. }
  22. }
  23. end
  24. def step(step_id:, action:, target:, params: {}, preconditions: [], evidence: [], risk: "low", include_source: false)
  25. merged_params = params || {}
  26. merged_params = merged_params.merge("source" => { "synced_email_id" => synced_email_id }) if include_source
  27. {
  28. "step_id" => step_id,
  29. "action" => action,
  30. "target" => target,
  31. "params" => merged_params,
  32. "preconditions" => Array(preconditions),
  33. "evidence" => Array(evidence),
  34. "risk" => risk.to_s
  35. }
  36. end
  37. def create_round(step_id:, params:, preconditions:, evidence:, risk: "low")
  38. step(
  39. step_id: step_id,
  40. action: "create_round",
  41. target: target(selector: "none"),
  42. params: params,
  43. preconditions: preconditions,
  44. evidence: evidence,
  45. risk: risk,
  46. include_source: true
  47. )
  48. end
  49. def set_pipeline_stage(step_id:, selector:, stage:, preconditions:, evidence:, risk: "low")
  50. step(
  51. step_id: step_id,
  52. action: "set_pipeline_stage",
  53. target: target(selector: selector),
  54. params: { "stage" => stage },
  55. preconditions: preconditions,
  56. evidence: evidence,
  57. risk: risk
  58. )
  59. end
  60. def set_application_status(step_id:, status:, preconditions:, evidence:, risk: "low")
  61. step(
  62. step_id: step_id,
  63. action: "set_application_status",
  64. target: target(selector: "none"),
  65. params: { "status" => status },
  66. preconditions: preconditions,
  67. evidence: evidence,
  68. risk: risk
  69. )
  70. end
  71. def set_round_result(step_id:, selector:, result:, completed_at:, preconditions:, evidence:, risk: "low")
  72. step(
  73. step_id: step_id,
  74. action: "set_round_result",
  75. target: target(selector: selector),
  76. params: { "result" => result, "completed_at" => completed_at || email_date },
  77. preconditions: preconditions,
  78. evidence: evidence,
  79. risk: risk,
  80. include_source: true
  81. )
  82. end
  83. def create_interview_feedback(step_id:, selector:, params:, preconditions:, evidence:, risk: "low")
  84. step(
  85. step_id: step_id,
  86. action: "create_interview_feedback",
  87. target: target(selector: selector),
  88. params: params.merge("round_selector" => selector),
  89. preconditions: preconditions,
  90. evidence: evidence,
  91. risk: risk,
  92. include_source: true
  93. )
  94. end
  95. def create_company_feedback(step_id:, params:, preconditions:, evidence:, risk: "low")
  96. step(
  97. step_id: step_id,
  98. action: "create_company_feedback",
  99. target: target(selector: "none"),
  100. params: params,
  101. preconditions: preconditions,
  102. evidence: evidence,
  103. risk: risk,
  104. include_source: true
  105. )
  106. end
  107. private
  108. attr_reader :application_id, :synced_email_id, :email_date
  109. end
  110. end
  111. end

app/domains/signals/services/facts/canonical_email_event_builder.rb

0.0% lines covered

100.0% branches covered

76 relevant lines. 0 lines covered and 76 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Facts
  4. # Builds the canonical email event payload for DecisionInput.
  5. #
  6. # Goal: ensure every downstream component (facts extraction, planner, semantic validation)
  7. # sees the *same* canonical email text.
  8. class CanonicalEmailEventBuilder
  9. REPLY_SEPARATORS = [
  10. /^On .+ wrote:$/i,
  11. /^On .+sent:$/i,
  12. /^On .+wrote$/i,
  13. /^From:\s+/i,
  14. /^Sent:\s+/i,
  15. /^To:\s+/i,
  16. /^Subject:\s+/i,
  17. /^-----Original Message-----/i,
  18. /^----- Forwarded message -----/i,
  19. /^Begin forwarded message:/i
  20. ].freeze
  21. URL_REGEX = %r{https?://[^\s<>"')]+}i
  22. def initialize(synced_email)
  23. @synced_email = synced_email
  24. end
  25. def build
  26. raw_text, source = best_body_source
  27. canonical = canonicalize_text(raw_text)
  28. links = extract_links(canonical)
  29. {
  30. "event_type" => "email",
  31. "synced_email_id" => synced_email.id,
  32. "thread_id" => synced_email.thread_id,
  33. "received_at" => synced_email.email_date&.iso8601,
  34. "email_date" => synced_email.email_date&.iso8601,
  35. "from" => {
  36. "email" => synced_email.from_email,
  37. "name" => synced_email.from_name
  38. },
  39. "to" => [],
  40. "subject" => synced_email.subject,
  41. "body" => {
  42. "text" => canonical,
  43. "source" => source,
  44. "truncated" => false,
  45. "normalization" => {
  46. "replies_removed" => true,
  47. "html_stripped" => source == "body_html",
  48. "whitespace_collapsed" => true
  49. }
  50. },
  51. "links" => links
  52. }
  53. end
  54. private
  55. attr_reader :synced_email
  56. def best_body_source
  57. if synced_email.body_preview.present?
  58. [ synced_email.body_preview.to_s, "body_preview" ]
  59. elsif synced_email.body_html.present?
  60. [ ActionController::Base.helpers.strip_tags(synced_email.body_html.to_s), "body_html" ]
  61. else
  62. [ synced_email.snippet.to_s, "snippet" ]
  63. end
  64. end
  65. def canonicalize_text(text)
  66. return "" if text.blank?
  67. normalized = text.to_s.gsub(/\r\n?/, "\n")
  68. lines = normalized.split("\n")
  69. cutoff = lines.index { |line| REPLY_SEPARATORS.any? { |rx| line.to_s.strip.match?(rx) } }
  70. kept = cutoff ? lines[0...cutoff] : lines
  71. kept = kept.reject { |line| line.lstrip.start_with?(">") }
  72. kept.join("\n").gsub(/\s+/, " ").strip
  73. end
  74. def extract_links(text)
  75. text.to_s.scan(URL_REGEX).uniq.first(50).map do |url|
  76. { "url" => url, "label_hint" => nil }
  77. end
  78. end
  79. end
  80. end
  81. end

app/domains/signals/services/facts/email_facts_extractor.rb

0.0% lines covered

100.0% branches covered

132 relevant lines. 0 lines covered and 132 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Facts
  4. # Extracts EmailFacts using an LLM and validates against the EmailFacts schema.
  5. #
  6. # Persists results onto synced_email.extracted_data under versioned keys.
  7. class EmailFactsExtractor < ApplicationService
  8. EMAIL_FACTS_SCHEMA_ID = "gleania://signals/contracts/schemas/components/facts/email_facts.schema.json"
  9. OPERATION_TYPE = :email_facts_extraction
  10. FACTS_KEY = "email_facts_v1"
  11. FACTS_META_KEY = "email_facts_meta_v1"
  12. def initialize(synced_email, decision_input_base:)
  13. @synced_email = synced_email
  14. @decision_input_base = decision_input_base
  15. end
  16. def call
  17. log_info("EmailFacts extraction start: synced_email_id=#{synced_email.id}")
  18. prompt = build_prompt
  19. prompt_template = Ai::EmailFactsExtractionPrompt.active_prompt
  20. system_message = prompt_template&.system_prompt.presence || Ai::EmailFactsExtractionPrompt.default_system_prompt
  21. runner = Ai::ProviderRunnerService.new(
  22. provider_chain: provider_chain,
  23. prompt: prompt,
  24. content_size: prompt.bytesize,
  25. system_message: system_message,
  26. provider_for: method(:get_provider_instance),
  27. run_options: { max_tokens: 2500, temperature: 0.1 },
  28. logger_builder: lambda { |provider_name, provider|
  29. Ai::ApiLoggerService.new(
  30. operation_type: OPERATION_TYPE,
  31. loggable: synced_email,
  32. provider: provider_name,
  33. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  34. llm_prompt: prompt_template
  35. )
  36. },
  37. operation: OPERATION_TYPE,
  38. loggable: synced_email,
  39. user: synced_email&.user,
  40. error_context: {
  41. severity: "warning",
  42. synced_email_id: synced_email.id,
  43. application_id: synced_email.interview_application_id
  44. }
  45. )
  46. result = runner.run do |response|
  47. parsed = parse_response(response[:content]) || {}
  48. schema_errors = schema_validator.errors_for(parsed)
  49. # We accept only if schema-valid.
  50. accept = schema_errors.empty?
  51. log_data = {
  52. schema_valid: accept,
  53. schema_error_count: schema_errors.size,
  54. classification_kind: parsed.dig("classification", "kind"),
  55. confidence: parsed.dig("extraction", "confidence")
  56. }.compact
  57. [ parsed, log_data, accept ]
  58. end
  59. unless result[:success]
  60. log_warning("EmailFacts extraction failed: synced_email_id=#{synced_email.id} error=#{result[:error]}")
  61. persist_meta(status: "failed", errors: [ { "message" => result[:error] } ])
  62. return { success: false, error: result[:error] }
  63. end
  64. facts = result[:parsed]
  65. persist_facts!(
  66. facts,
  67. meta: {
  68. "status" => "ok",
  69. "provider" => result[:provider],
  70. "model" => result[:model],
  71. "llm_api_log_id" => result[:llm_api_log_id],
  72. "latency_ms" => result[:latency_ms],
  73. "generated_at" => Time.current.iso8601
  74. }
  75. )
  76. log_info("EmailFacts extraction ok: synced_email_id=#{synced_email.id} kind=#{facts.dig("classification", "kind")}")
  77. { success: true, facts: facts, llm_api_log_id: result[:llm_api_log_id] }
  78. rescue StandardError => e
  79. notify_error(
  80. e,
  81. context: "signals_email_facts_extractor",
  82. severity: "warning",
  83. user: synced_email&.user,
  84. synced_email_id: synced_email&.id,
  85. application_id: synced_email&.interview_application_id
  86. )
  87. log_error("EmailFacts extraction exception: synced_email_id=#{synced_email&.id} #{e.class}: #{e.message}")
  88. persist_meta(status: "exception", errors: [ { "message" => e.message, "class" => e.class.name } ])
  89. { success: false, error: e.message }
  90. end
  91. private
  92. attr_reader :synced_email, :decision_input_base
  93. def schema_validator
  94. @schema_validator ||= Signals::Contracts::Validators::JsonSchemaValidator.new(schema_id: EMAIL_FACTS_SCHEMA_ID)
  95. end
  96. def build_prompt
  97. event = decision_input_base.fetch("event")
  98. app_snapshot = decision_input_base["application"]
  99. vars = {
  100. subject: event["subject"].to_s,
  101. body: event.dig("body", "text").to_s,
  102. from_email: event.dig("from", "email").to_s,
  103. from_name: event.dig("from", "name").to_s,
  104. email_type: synced_email.email_type.to_s,
  105. application_snapshot: app_snapshot ? JSON.pretty_generate(app_snapshot) : "null"
  106. }
  107. Ai::PromptBuilderService.new(
  108. prompt_class: Ai::EmailFactsExtractionPrompt,
  109. variables: vars
  110. ).run
  111. end
  112. def parse_response(content)
  113. Ai::ResponseParserService.new(content).parse(symbolize: false)
  114. end
  115. def provider_chain
  116. LlmProviders::ProviderConfigHelper.all_providers
  117. end
  118. def get_provider_instance(provider_name)
  119. case provider_name.to_s.downcase
  120. when "openai" then LlmProviders::OpenaiProvider.new
  121. when "anthropic" then LlmProviders::AnthropicProvider.new
  122. when "ollama" then LlmProviders::OllamaProvider.new
  123. else nil
  124. end
  125. end
  126. def persist_meta(status:, errors:)
  127. persist_facts!(nil, meta: { "status" => status, "errors" => errors, "generated_at" => Time.current.iso8601 })
  128. end
  129. def persist_facts!(facts, meta:)
  130. existing = synced_email.extracted_data.is_a?(Hash) ? synced_email.extracted_data.deep_dup : {}
  131. existing[FACTS_KEY] = facts
  132. existing[FACTS_META_KEY] = meta
  133. synced_email.update!(extracted_data: existing)
  134. end
  135. end
  136. end
  137. end

app/domains/signals/services/observability/email_pipeline_recorder.rb

0.0% lines covered

100.0% branches covered

135 relevant lines. 0 lines covered and 135 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Observability
  4. # Best-effort recorder for end-to-end email pipeline runs/events.
  5. #
  6. # Mirrors the ScrapingAttempt/ScrapingEvent pattern, but for the Gmail → Signals pipeline.
  7. class EmailPipelineRecorder < ApplicationService
  8. def self.start_for(synced_email:, user:, connected_account:, trigger:, mode:, metadata: {})
  9. run = Signals::EmailPipelineRun.create!(
  10. synced_email: synced_email,
  11. user: user,
  12. connected_account: connected_account,
  13. status: :started,
  14. trigger: trigger.to_s,
  15. mode: mode.to_s,
  16. started_at: Time.current,
  17. metadata: metadata || {}
  18. )
  19. new(run)
  20. end
  21. def self.for_run(run)
  22. return nil unless run
  23. new(run)
  24. end
  25. def initialize(run)
  26. @run = run
  27. end
  28. attr_reader :run
  29. # Records a simple point-in-time event.
  30. def event!(event_type:, status:, input_payload: {}, output_payload: {}, error: nil, metadata: {})
  31. step_order = run.next_step_order
  32. now = Time.current
  33. Signals::EmailPipelineEvent.create!(
  34. run: run,
  35. synced_email: run.synced_email,
  36. interview_application: run.synced_email.interview_application,
  37. step_order: step_order,
  38. event_type: event_type.to_s,
  39. status: status.to_s,
  40. started_at: now,
  41. completed_at: now,
  42. duration_ms: 0,
  43. input_payload: input_payload || {},
  44. output_payload: output_payload || {},
  45. error_type: error&.class&.name,
  46. error_message: error&.message,
  47. metadata: metadata || {}
  48. )
  49. rescue StandardError => e
  50. log_warning("EmailPipelineRecorder event failed: run_id=#{run&.id} #{e.class}: #{e.message}")
  51. nil
  52. end
  53. # Measures a step event around a block, capturing duration and status.
  54. #
  55. # If the block returns a Hash, it is stored as output_payload. Otherwise it is stored as:
  56. # { \"result\" => <returned value> }.
  57. # @param output_payload_override [Hash, Proc, nil]
  58. # - Hash: stored as output_payload
  59. # - Proc: called with the block result, stored output
  60. def measure(event_type, input_payload: {}, output_payload_override: nil, metadata: {})
  61. step_order = run.next_step_order
  62. started_at = Time.current
  63. start_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  64. event = Signals::EmailPipelineEvent.create!(
  65. run: run,
  66. synced_email: run.synced_email,
  67. interview_application: run.synced_email.interview_application,
  68. step_order: step_order,
  69. event_type: event_type.to_s,
  70. status: :started,
  71. started_at: started_at,
  72. input_payload: input_payload || {},
  73. output_payload: {},
  74. metadata: metadata || {}
  75. )
  76. result = yield
  77. completed_at = Time.current
  78. end_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  79. duration_ms = ((end_monotonic - start_monotonic) * 1000).round
  80. output_payload =
  81. if output_payload_override.respond_to?(:call)
  82. output_payload_override.call(result)
  83. else
  84. output_payload_override
  85. end
  86. output_payload ||= (result.is_a?(Hash) ? result : { "result" => result })
  87. event.update!(
  88. status: :success,
  89. completed_at: completed_at,
  90. duration_ms: duration_ms,
  91. output_payload: output_payload || {}
  92. )
  93. result
  94. rescue StandardError => e
  95. completed_at = Time.current
  96. duration_ms =
  97. begin
  98. end_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  99. ((end_monotonic - start_monotonic) * 1000).round
  100. rescue StandardError
  101. nil
  102. end
  103. begin
  104. event&.update!(
  105. status: :failed,
  106. completed_at: completed_at,
  107. duration_ms: duration_ms,
  108. error_type: e.class.name,
  109. error_message: e.message,
  110. output_payload: { "error" => e.message }
  111. )
  112. rescue StandardError => update_err
  113. log_warning("EmailPipelineRecorder failed to update event: run_id=#{run&.id} #{update_err.class}: #{update_err.message}")
  114. end
  115. raise
  116. end
  117. def finish_success!(metadata: {})
  118. finish!(status: :success, metadata: metadata)
  119. end
  120. def finish_failed!(exception, metadata: {})
  121. finish!(
  122. status: :failed,
  123. error_type: exception.class.name,
  124. error_message: exception.message,
  125. metadata: metadata
  126. )
  127. end
  128. def finish!(status:, error_type: nil, error_message: nil, metadata: {})
  129. completed_at = Time.current
  130. duration_ms = ((completed_at - run.started_at) * 1000).round if run.started_at
  131. merged = (run.metadata.is_a?(Hash) ? run.metadata.deep_dup : {})
  132. merged.merge!(metadata || {})
  133. run.update!(
  134. status: status,
  135. completed_at: completed_at,
  136. duration_ms: duration_ms,
  137. error_type: error_type,
  138. error_message: error_message,
  139. metadata: merged
  140. )
  141. rescue StandardError => e
  142. log_warning("EmailPipelineRecorder finish failed: run_id=#{run&.id} #{e.class}: #{e.message}")
  143. nil
  144. end
  145. end
  146. end
  147. end

app/helpers/application_helper.rb

17.65% lines covered

0.0% branches covered

17 relevant lines. 3 lines covered and 14 lines missed.
13 total branches, 0 branches covered and 13 branches missed.
    
  1. 1 module ApplicationHelper
  2. # Returns a time-based greeting message
  3. #
  4. # @return [String] Greeting based on current time
  5. 1 def greeting_message
  6. hour = Time.current.hour
  7. when: 0 case hour
  8. when: 0 when 5..11 then "Good morning"
  9. when: 0 when 12..16 then "Good afternoon"
  10. else: 0 when 17..20 then "Good evening"
  11. else "Hello"
  12. end
  13. end
  14. # Returns the color class for an email type indicator bar
  15. #
  16. # @param email_type [String] The email type
  17. # @return [String] Tailwind CSS classes for the color
  18. 1 def email_type_color_class(email_type)
  19. then: 0 else: 0 case email_type&.to_s
  20. when: 0 when "offer"
  21. "bg-emerald-500"
  22. when: 0 when "rejection"
  23. "bg-red-500"
  24. when: 0 when "interview_invite"
  25. "bg-blue-500"
  26. when: 0 when "follow_up"
  27. "bg-amber-500"
  28. when: 0 when "confirmation", "application_confirmation"
  29. "bg-purple-500"
  30. when: 0 when "scheduling"
  31. "bg-cyan-500"
  32. else: 0 else
  33. "bg-gray-300 dark:bg-gray-600"
  34. end
  35. end
  36. end

app/helpers/assistant_helper.rb

30.0% lines covered

0.0% branches covered

10 relevant lines. 3 lines covered and 7 lines missed.
4 total branches, 0 branches covered and 4 branches missed.
    
  1. # frozen_string_literal: true
  2. # Helper methods for assistant chat views.
  3. 1 module AssistantHelper
  4. # Renders markdown content with syntax highlighting for assistant messages.
  5. # User messages are returned as plain text for simplicity.
  6. #
  7. # @param message [Assistant::ChatMessage] The chat message
  8. # @return [String] Safe HTML string
  9. 1 def render_chat_message(message)
  10. content = message.content.to_s
  11. then: 0 if message.role == "assistant"
  12. render_assistant_markdown(content)
  13. else
  14. else: 0 # User messages: simple HTML escape with line breaks
  15. simple_format(h(content), {}, wrapper_tag: "span")
  16. end
  17. end
  18. # Renders markdown to HTML with syntax highlighting for assistant responses.
  19. #
  20. # @param text [String] The markdown text
  21. # @return [String] Safe HTML string
  22. 1 def render_assistant_markdown(text)
  23. then: 0 else: 0 return "" if text.blank?
  24. # Use the existing MarkdownRenderer service
  25. html = MarkdownRenderer.render(text)
  26. # Wrap in a container with chat-specific prose styling
  27. content_tag(:div, html, class: "chat-prose")
  28. end
  29. end

app/helpers/billing/entitlements_helper.rb

58.82% lines covered

100.0% branches covered

17 relevant lines. 10 lines covered and 7 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Billing
  3. # View helpers for checking entitlements in ERB templates.
  4. 1 module EntitlementsHelper
  5. # @param feature_key [String, Symbol]
  6. # @return [Boolean]
  7. 1 def entitled?(feature_key)
  8. current_entitlements.allowed?(feature_key)
  9. end
  10. # @param feature_key [String, Symbol]
  11. # @return [Integer, nil]
  12. 1 def entitlement_remaining(feature_key)
  13. current_entitlements.remaining(feature_key)
  14. end
  15. # @return [Boolean]
  16. 1 def insight_trial_active?
  17. current_entitlements.insight_trial_active?
  18. end
  19. # @return [String, nil]
  20. 1 def insight_trial_time_remaining_in_words
  21. current_entitlements.insight_trial_time_remaining_in_words
  22. end
  23. # @return [Symbol]
  24. 1 def subscription_status
  25. current_entitlements.subscription_status
  26. end
  27. # @return [Billing::Plan, nil]
  28. 1 def current_plan
  29. current_entitlements.plan
  30. end
  31. 1 private
  32. 1 def current_entitlements
  33. @current_entitlements ||= Billing::Entitlements.for(Current.user)
  34. end
  35. end
  36. end

app/helpers/internal/developer/base_helper.rb

8.07% lines covered

0.0% branches covered

731 relevant lines. 59 lines covered and 672 lines missed.
476 total branches, 0 branches covered and 476 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Internal
  3. 1 module Developer
  4. # Helper methods for the developer portal
  5. 1 module BaseHelper
  6. 1 include Pagy::Frontend
  7. 1 include Internal::Developer::CustomRenderersHelper
  8. # Returns the color scheme for a portal
  9. #
  10. # @param portal_key [Symbol] Portal identifier
  11. # @return [String]
  12. 1 def portal_color(portal_key)
  13. when: 0 case portal_key
  14. when: 0 when :ops then "amber"
  15. when: 0 when :ai then "cyan"
  16. when: 0 when :assistant then "violet"
  17. else: 0 when :email then "emerald"
  18. else "slate"
  19. end
  20. end
  21. # Returns the icon SVG for a portal
  22. #
  23. # @param portal_key [Symbol] Portal identifier
  24. # @return [String] HTML safe SVG icon
  25. 1 def portal_icon(portal_key)
  26. case portal_key
  27. when: 0 when :ops
  28. '<svg class="w-3 h-3 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>'.html_safe
  29. when: 0 when :ai
  30. '<svg class="w-3 h-3 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe
  31. when: 0 when :assistant
  32. '<svg class="w-3 h-3 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'.html_safe
  33. when: 0 when :email
  34. '<svg class="w-3 h-3 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h6m-6 4h10M5 6a2 2 0 012-2h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6z"/></svg>'.html_safe
  35. else: 0 else
  36. '<svg class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>'.html_safe
  37. end
  38. end
  39. # Renders a column value from a record
  40. #
  41. # @param record [ActiveRecord::Base] The record
  42. # @param column [ColumnDefinition] Column definition
  43. # @return [String]
  44. 1 def render_column_value(record, column)
  45. # Handle toggle columns specially
  46. then: 0 if column.type == :toggle
  47. field = column.toggle_field || column.name
  48. render partial: "internal/developer/shared/toggle_cell",
  49. else: 0 locals: { record: record, field: field }
  50. then: 0 elsif column.type == :label
  51. then: 0 else: 0 value = column.content.is_a?(Proc) ? column.content.call(record) : (record.public_send(column.name) rescue nil)
  52. else: 0 render_label_badge(value, color: column.label_color, size: column.label_size, record: record)
  53. then: 0 elsif column.content.is_a?(Proc)
  54. column.content.call(record)
  55. else: 0 else
  56. record.public_send(column.name) rescue "—"
  57. end
  58. end
  59. # Formats a value for display on show pages
  60. #
  61. # @param record [ActiveRecord::Base] The record
  62. # @param field_name [Symbol, String] Field name
  63. # @return [String] HTML safe formatted value
  64. 1 def format_show_value(record, field_name)
  65. value = record.public_send(field_name) rescue nil
  66. # Handle Active Storage attachments
  67. then: 0 if value.is_a?(ActiveStorage::Attached::One)
  68. else: 0 return render_attachment_preview(value)
  69. then: 0 else: 0 elsif value.is_a?(ActiveStorage::Attached::Many)
  70. return render_attachments_preview(value)
  71. end
  72. case value
  73. when: 0 when nil
  74. content_tag(:span, "—", class: "text-slate-400")
  75. when: 0 when true
  76. content_tag(:span, class: "inline-flex items-center gap-1") do
  77. svg = '<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>'.html_safe
  78. concat(svg)
  79. concat(content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
  80. end
  81. when: 0 when false
  82. content_tag(:span, class: "inline-flex items-center gap-1") do
  83. svg = '<svg class="w-4 h-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'.html_safe
  84. concat(svg)
  85. concat(content_tag(:span, "No", class: "text-slate-500"))
  86. end
  87. when: 0 when Time, DateTime
  88. content_tag(:span, class: "inline-flex items-center gap-2") do
  89. concat(content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
  90. concat(content_tag(:span, "(#{time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
  91. end
  92. when: 0 when Date
  93. value.strftime("%B %d, %Y")
  94. when: 0 when ActiveRecord::Base
  95. then: 0 else: 0 link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
  96. content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
  97. when: 0 when Hash
  98. render_json_block(value)
  99. when: 0 when Array
  100. then: 0 if value.empty?
  101. else: 0 content_tag(:span, "Empty array", class: "text-slate-400 italic")
  102. then: 0 elsif value.first.is_a?(Hash)
  103. render_json_block(value)
  104. else: 0 else
  105. content_tag(:div, class: "flex flex-wrap gap-1") do
  106. value.each do |item|
  107. concat(content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"))
  108. end
  109. end
  110. end
  111. when: 0 when Integer, Float, BigDecimal
  112. content_tag(:span, number_with_delimiter(value), class: "font-mono")
  113. else: 0 else
  114. value_str = value.to_s
  115. # Check if it looks like JSON
  116. then: 0 if value_str.start_with?("{", "[") && value_str.length > 10
  117. begin
  118. parsed = JSON.parse(value_str)
  119. render_json_block(parsed)
  120. rescue JSON::ParserError
  121. render_text_block(value_str)
  122. end
  123. else: 0 # Check if it's multi-line or long text (likely code/template)
  124. then: 0 elsif value_str.include?("\n") || value_str.length > 200
  125. render_text_block(value_str, detect_language(field_name, value_str))
  126. else
  127. else: 0 # Regular text
  128. value_str
  129. end
  130. end
  131. end
  132. # Renders an Active Storage attachment preview
  133. #
  134. # @param attachment [ActiveStorage::Attached::One] The attachment
  135. # @return [String] HTML safe attachment preview
  136. 1 def render_attachment_preview(attachment)
  137. else: 0 then: 0 return content_tag(:span, "—", class: "text-slate-400") unless attachment.attached?
  138. blob = attachment.blob
  139. then: 0 if blob.image?
  140. content_tag(:div, class: "space-y-2") do
  141. concat(content_tag(:div, class: "inline-block rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
  142. image_tag(attachment.variant(resize_to_limit: [ 600, 400 ]),
  143. class: "max-w-full h-auto max-h-64 object-contain",
  144. alt: blob.filename.to_s)
  145. end)
  146. concat(content_tag(:div, class: "flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400") do
  147. concat(content_tag(:span, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300"))
  148. concat(content_tag(:span, "•"))
  149. concat(content_tag(:span, number_to_human_size(blob.byte_size)))
  150. concat(content_tag(:span, "•"))
  151. concat(link_to("View full size", rails_blob_path(blob, disposition: :inline),
  152. target: "_blank",
  153. class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
  154. end)
  155. end
  156. else: 0 else
  157. content_tag(:div, class: "flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700") do
  158. concat(content_tag(:div, class: "flex-shrink-0 w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg flex items-center justify-center") do
  159. '<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
  160. end)
  161. concat(content_tag(:div, class: "flex-1 min-w-0") do
  162. concat(content_tag(:p, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300 truncate"))
  163. concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-sm text-slate-500 dark:text-slate-400"))
  164. end)
  165. concat(link_to("Download", rails_blob_path(blob, disposition: :attachment),
  166. class: "flex-shrink-0 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors"))
  167. end
  168. end
  169. end
  170. # Renders multiple Active Storage attachments preview
  171. #
  172. # @param attachments [ActiveStorage::Attached::Many] The attachments
  173. # @return [String] HTML safe attachments preview
  174. 1 def render_attachments_preview(attachments)
  175. else: 0 then: 0 return content_tag(:span, "—", class: "text-slate-400") unless attachments.attached?
  176. content_tag(:div, class: "grid grid-cols-2 md:grid-cols-3 gap-4") do
  177. attachments.each do |attachment|
  178. concat(render_attachment_preview(attachment))
  179. end
  180. end
  181. end
  182. # Renders a JSON block with syntax highlighting
  183. #
  184. # @param data [Hash, Array] The data to render
  185. # @return [String] HTML safe JSON block
  186. 1 def render_json_block(data)
  187. json_str = JSON.pretty_generate(data)
  188. content_tag(:div, class: "relative group") do
  189. concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
  190. concat(content_tag(:span, "JSON", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
  191. concat(content_tag(:button,
  192. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
  193. type: "button",
  194. class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
  195. data: { controller: "clipboard", action: "click->clipboard#copy", clipboard_text_value: json_str },
  196. title: "Copy to clipboard"))
  197. end)
  198. concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto") do
  199. content_tag(:code, class: "language-json") do
  200. highlight_json(json_str)
  201. end
  202. end)
  203. end
  204. end
  205. # Renders a text/code block
  206. #
  207. # @param text [String] The text to render
  208. # @param language [Symbol, nil] Optional language for syntax highlighting
  209. # @return [String] HTML safe text block
  210. 1 def render_text_block(text, language = nil)
  211. content_tag(:div, class: "relative group") do
  212. concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
  213. then: 0 else: 0 if language
  214. concat(content_tag(:span, language.to_s.upcase, class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
  215. end
  216. concat(content_tag(:button,
  217. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
  218. type: "button",
  219. class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
  220. data: { controller: "clipboard", action: "click->clipboard#copy", clipboard_text_value: text },
  221. title: "Copy to clipboard"))
  222. end)
  223. concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto whitespace-pre-wrap") do
  224. then: 0 else: 0 content_tag(:code, h(text), class: language ? "language-#{language}" : nil)
  225. end)
  226. end
  227. end
  228. # Highlights JSON string with colors
  229. #
  230. # @param json_str [String] The JSON string
  231. # @return [String] HTML safe highlighted JSON
  232. 1 def highlight_json(json_str)
  233. # Simple JSON syntax highlighting
  234. highlighted = h(json_str)
  235. .gsub(/("(?:[^"\\]|\\.)*")(\s*:)/) do |match|
  236. key = $1
  237. colon = $2
  238. "<span class=\"text-purple-400\">#{key}</span>#{colon}"
  239. end
  240. .gsub(/:\s*("(?:[^"\\]|\\.)*")/) do |match|
  241. ":<span class=\"text-green-400\">#{$1}</span>"
  242. end
  243. .gsub(/:\s*(true|false)/) do |match|
  244. ":<span class=\"text-orange-400\">#{$1}</span>"
  245. end
  246. .gsub(/:\s*(-?\d+(?:\.\d+)?)/) do |match|
  247. ":<span class=\"text-cyan-400\">#{$1}</span>"
  248. end
  249. .gsub(/:\s*(null)/) do |match|
  250. ":<span class=\"text-red-400\">#{$1}</span>"
  251. end
  252. highlighted.html_safe
  253. end
  254. # Detects the language type from field name and content
  255. #
  256. # @param field_name [Symbol, String] Field name
  257. # @param content [String] Content to analyze
  258. # @return [Symbol, nil] Detected language
  259. 1 def detect_language(field_name, content)
  260. field_str = field_name.to_s.downcase
  261. # Check field name hints
  262. then: 0 else: 0 return :markdown if field_str.include?("template") || field_str.include?("prompt")
  263. then: 0 else: 0 return :ruby if field_str.include?("code") && content.include?("def ")
  264. then: 0 else: 0 return :sql if field_str.include?("query") || field_str.include?("sql")
  265. then: 0 else: 0 return :html if field_str.include?("html") || field_str.include?("body")
  266. # Check content hints
  267. then: 0 else: 0 return :json if content.strip.start_with?("{", "[")
  268. then: 0 else: 0 return :ruby if content.include?("def ") || content.include?("class ")
  269. then: 0 else: 0 return :sql if content.upcase.include?("SELECT ") || content.upcase.include?("INSERT ")
  270. then: 0 else: 0 return :html if content.include?("<html") || content.include?("<div")
  271. # Default to text for multi-line content
  272. nil
  273. end
  274. # Renders a custom section based on the render type
  275. #
  276. # @param resource [ActiveRecord::Base] The record
  277. # @param render_type [Symbol] Type of custom render
  278. # @return [String] HTML safe rendered content
  279. 1 def render_custom_section(resource, render_type)
  280. then: 0 else: 0 if defined?(AdminSuite)
  281. renderer = AdminSuite.config.custom_renderers[render_type.to_sym] rescue nil
  282. then: 0 else: 0 return renderer.call(resource, self) if renderer
  283. end
  284. case render_type
  285. when: 0 when :prompt_template_preview
  286. render_prompt_template(resource)
  287. when: 0 when :json_preview
  288. render_json_preview(resource)
  289. when: 0 when :code_preview
  290. render_code_preview(resource)
  291. when: 0 when :messages_preview
  292. render_messages_preview(resource)
  293. when: 0 when :tool_args_preview
  294. render_tool_args_preview(resource)
  295. when: 0 when :turn_messages_preview
  296. render_turn_messages_preview(resource)
  297. else: 0 else
  298. content_tag(:p, "Unknown render type: #{render_type}", class: "text-slate-500 italic")
  299. end
  300. end
  301. # Attempts to build an internal developer portal show path for an associated record.
  302. # This provides default navigation links for associations even when a resource definition
  303. # doesn't explicitly set `link_to:`.
  304. #
  305. # @param item [ActiveRecord::Base]
  306. # @return [String, nil]
  307. 1 def auto_internal_developer_path_for(item)
  308. else: 0 then: 0 return nil unless item.is_a?(ActiveRecord::Base)
  309. ensure_admin_resources_loaded_for!(item.class)
  310. resource = Admin::Base::Resource.registered_resources.find { |r| r.model_class == item.class }
  311. then: 0 else: 0 else: 0 then: 0 return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
  312. "/internal/developer/#{resource.portal_name}/#{resource.resource_name_plural}/#{item.to_param}"
  313. rescue StandardError
  314. nil
  315. end
  316. 1 def ensure_admin_resources_loaded_for!(model_class)
  317. already_loaded = Admin::Base::Resource.registered_resources.any? { |r| r.model_class == model_class }
  318. then: 0 else: 0 return if already_loaded
  319. Dir[Rails.root.join("app/admin/resources/*.rb").to_s].each do |file|
  320. require file
  321. end
  322. rescue NameError
  323. require "admin/base/resource"
  324. retry
  325. end
  326. # Renders a prompt template with variable highlighting
  327. #
  328. # @param resource [ActiveRecord::Base] The record
  329. # @return [String] HTML safe template preview
  330. 1 def render_prompt_template(resource)
  331. then: 0 else: 0 template = resource.respond_to?(:prompt_template) ? resource.prompt_template : nil
  332. then: 0 else: 0 return content_tag(:p, "No template defined", class: "text-slate-500 italic") if template.blank?
  333. # Highlight template variables
  334. highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do |match|
  335. "<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
  336. end
  337. content_tag(:div, class: "relative group") do
  338. concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
  339. concat(content_tag(:span, "TEMPLATE", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
  340. concat(content_tag(:button,
  341. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
  342. type: "button",
  343. class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
  344. data: { controller: "clipboard", action: "click->clipboard#copy", clipboard_text_value: template },
  345. title: "Copy to clipboard"))
  346. end)
  347. concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-[600px] overflow-y-auto whitespace-pre-wrap leading-relaxed") do
  348. highlighted_template.html_safe
  349. end)
  350. # Show template variables
  351. variables = template.scan(/\{\{(\w+)\}\}/).flatten.uniq
  352. then: 0 else: 0 if variables.any?
  353. concat(content_tag(:div, class: "mt-3 pt-3 border-t border-slate-700") do
  354. concat(content_tag(:span, "Variables: ", class: "text-sm text-slate-400"))
  355. concat(content_tag(:div, class: "inline-flex flex-wrap gap-1 mt-1") do
  356. variables.each do |var|
  357. concat(content_tag(:code, "{{#{var}}}", class: "text-xs px-2 py-0.5 bg-amber-900/30 text-amber-400 rounded"))
  358. end
  359. end)
  360. end)
  361. end
  362. end
  363. end
  364. # Renders a JSON preview (for arbitrary JSON fields)
  365. #
  366. # @param resource [ActiveRecord::Base] The record
  367. # @return [String] HTML safe JSON preview
  368. 1 def render_json_preview(resource)
  369. then: 0 else: 0 data = resource.respond_to?(:data) ? resource.data : resource.attributes
  370. render_json_block(data)
  371. end
  372. # Renders a code preview
  373. #
  374. # @param resource [ActiveRecord::Base] The record
  375. # @return [String] HTML safe code preview
  376. 1 def render_code_preview(resource)
  377. then: 0 else: 0 code = resource.respond_to?(:code) ? resource.code : resource.to_s
  378. render_text_block(code, :ruby)
  379. end
  380. # Renders messages preview (for chat threads)
  381. #
  382. # @param resource [ActiveRecord::Base] The record
  383. # @return [String] HTML safe messages preview
  384. 1 def render_messages_preview(resource)
  385. then: 0 else: 0 messages = resource.respond_to?(:messages) ? resource.messages.chronological.limit(50) : []
  386. then: 0 else: 0 return content_tag(:p, "No messages", class: "text-slate-500 italic") if messages.blank?
  387. content_tag(:div, class: "space-y-4 max-h-[600px] overflow-y-auto -mx-6 -mb-6 p-6 pt-0") do
  388. messages.each_with_index do |msg, idx|
  389. # Handle both ActiveRecord objects and Hash messages
  390. then: 0 if msg.respond_to?(:role)
  391. role = msg.role
  392. content = msg.content
  393. created_at = msg.created_at
  394. else: 0 else
  395. role = msg["role"] || msg[:role] || "unknown"
  396. content = msg["content"] || msg[:content] || ""
  397. created_at = nil
  398. end
  399. when: 0 role_class = case role.to_s
  400. when: 0 when "user" then "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
  401. when: 0 when "assistant" then "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800"
  402. when: 0 when "tool" then "bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800"
  403. else: 0 when "system" then "bg-slate-50 dark:bg-slate-700/50 border-slate-200 dark:border-slate-600"
  404. else "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
  405. end
  406. role_icon = case role.to_s
  407. when: 0 when "user"
  408. '<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe
  409. when: 0 when "assistant"
  410. '<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe
  411. when: 0 when "tool"
  412. '<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg>'.html_safe
  413. else: 0 else
  414. '<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'.html_safe
  415. end
  416. concat(content_tag(:div, class: "rounded-lg border p-4 #{role_class}") do
  417. concat(content_tag(:div, class: "flex items-center justify-between mb-3") do
  418. concat(content_tag(:div, class: "flex items-center gap-2") do
  419. concat(role_icon)
  420. concat(content_tag(:span, role.to_s.capitalize, class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
  421. end)
  422. concat(content_tag(:div, class: "flex items-center gap-2 text-xs text-slate-400") do
  423. then: 0 else: 0 if created_at
  424. concat(content_tag(:span, created_at.strftime("%H:%M:%S")))
  425. end
  426. concat(content_tag(:span, "##{idx + 1}"))
  427. end)
  428. end)
  429. # Render content - detect if it's JSON or code
  430. content_str = content.to_s
  431. then: 0 if role.to_s == "tool" && content_str.start_with?("{", "[")
  432. begin
  433. parsed = JSON.parse(content_str)
  434. concat(render_json_block(parsed))
  435. rescue JSON::ParserError
  436. concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
  437. end
  438. else: 0 else
  439. concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
  440. end
  441. end)
  442. end
  443. end
  444. end
  445. # Renders tool arguments preview
  446. #
  447. # @param resource [ActiveRecord::Base] The record
  448. # @return [String] HTML safe tool args preview
  449. 1 def render_tool_args_preview(resource)
  450. # ToolExecution uses 'args' not 'arguments'
  451. then: 0 else: 0 then: 0 else: 0 args = resource.respond_to?(:args) ? resource.args : (resource.respond_to?(:arguments) ? resource.arguments : {})
  452. then: 0 else: 0 result = resource.respond_to?(:result) ? resource.result : nil
  453. then: 0 else: 0 error = resource.respond_to?(:error) ? resource.error : nil
  454. content_tag(:div, class: "space-y-6") do
  455. # Arguments section
  456. concat(content_tag(:div) do
  457. concat(content_tag(:h4, "Arguments", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
  458. then: 0 if args.present? && args != {}
  459. concat(render_json_block(args))
  460. else: 0 else
  461. concat(content_tag(:p, "No arguments", class: "text-slate-400 italic text-sm"))
  462. end
  463. end)
  464. # Result section
  465. then: 0 else: 0 if result.present? && result != {}
  466. concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
  467. concat(content_tag(:h4, "Result", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
  468. concat(render_json_block(result))
  469. end)
  470. end
  471. # Error section
  472. then: 0 else: 0 if error.present?
  473. concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
  474. concat(content_tag(:h4, "Error", class: "text-sm font-medium text-red-500 dark:text-red-400 mb-2"))
  475. concat(content_tag(:div, class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4") do
  476. content_tag(:pre, h(error.to_s), class: "text-sm text-red-700 dark:text-red-300 whitespace-pre-wrap font-mono")
  477. end)
  478. end)
  479. end
  480. end
  481. end
  482. # Renders a show page section based on its configuration
  483. #
  484. # @param resource [ActiveRecord::Base] The record
  485. # @param section [ShowSectionDefinition] Section definition
  486. # @param position [Symbol] :sidebar or :main
  487. # @return [String] HTML safe section
  488. 1 def render_show_section(resource, section, position = :main)
  489. # Check if this is an association section (needs tighter header-content spacing)
  490. is_association = section.association.present? && !resource.public_send(section.association).is_a?(ActiveRecord::Base) rescue false
  491. content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
  492. # Header
  493. then: 0 else: 0 header_padding = position == :sidebar ? "px-4 py-2.5" : "px-6 py-3"
  494. then: 0 else: 0 header_text_size = position == :sidebar ? "text-sm" : ""
  495. then: 0 else: 0 header_border = is_association ? "" : "border-b border-slate-200 dark:border-slate-700"
  496. concat(content_tag(:div, class: "#{header_padding} #{header_border} bg-slate-50 dark:bg-slate-900/50 flex items-center justify-between") do
  497. concat(content_tag(:h3, section.title, class: "font-medium text-slate-900 dark:text-white #{header_text_size}"))
  498. # Show count for associations
  499. then: 0 else: 0 if section.association.present?
  500. assoc = resource.public_send(section.association) rescue nil
  501. then: 0 else: 0 if assoc && !assoc.is_a?(ActiveRecord::Base)
  502. count = assoc.count rescue 0
  503. then: 0 else: 0 color_class = count > 0 ? "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400" : "bg-slate-200 dark:bg-slate-600 text-slate-600 dark:text-slate-300"
  504. concat(content_tag(:span, number_with_delimiter(count), class: "text-xs font-semibold px-2 py-0.5 rounded-full #{color_class}"))
  505. end
  506. end
  507. end)
  508. # Content
  509. then: 0 else: 0 content_padding = position == :sidebar ? "p-4" : "p-6"
  510. then: 0 else: 0 if is_association && position == :main
  511. then: 0 else: 0 content_padding = section.paginate ? "pt-0 px-6 pb-0" : "pt-0 px-6 pb-6"
  512. end
  513. then: 0 else: 0 content_padding = "pt-0 p-4" if is_association && position == :sidebar
  514. concat(content_tag(:div, class: content_padding) do
  515. if section.render.present?
  516. then: 0 # Custom renderer
  517. else: 0 render_custom_section(resource, section.render)
  518. elsif section.association.present?
  519. then: 0 # Association display
  520. else: 0 render_association_section(resource, section)
  521. elsif section.fields.any?
  522. then: 0 # Field display
  523. then: 0 if position == :sidebar
  524. render_sidebar_fields(resource, section.fields)
  525. else: 0 else
  526. render_main_fields(resource, section.fields)
  527. end
  528. else: 0 else
  529. content_tag(:p, "No content", class: "text-slate-400 italic text-sm")
  530. end
  531. end)
  532. end
  533. end
  534. # Renders fields for sidebar (compact layout)
  535. #
  536. # @param resource [ActiveRecord::Base] The record
  537. # @param fields [Array<Symbol>] Field names
  538. # @return [String] HTML safe fields
  539. 1 def render_sidebar_fields(resource, fields)
  540. content_tag(:div, class: "space-y-3") do
  541. fields.each do |field_name|
  542. value = resource.public_send(field_name) rescue nil
  543. # Special handling for attachments in sidebar - show image prominently
  544. then: 0 if value.is_a?(ActiveStorage::Attached::One) || value.is_a?(ActiveStorage::Attached::Many)
  545. concat(render_sidebar_attachment(value))
  546. else: 0 else
  547. concat(content_tag(:div, class: "flex justify-between items-start gap-2") do
  548. concat(content_tag(:span, field_name.to_s.humanize, class: "text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider flex-shrink-0"))
  549. concat(content_tag(:span, class: "text-sm text-slate-900 dark:text-white text-right") do
  550. format_show_value(resource, field_name)
  551. end)
  552. end)
  553. end
  554. end
  555. end
  556. end
  557. # Renders an attachment for sidebar display (compact, image-focused)
  558. #
  559. # @param attachment [ActiveStorage::Attached] The attachment
  560. # @return [String] HTML safe attachment preview
  561. 1 def render_sidebar_attachment(attachment)
  562. else: 0 then: 0 return content_tag(:div, class: "text-center py-4") do
  563. content_tag(:span, "No image", class: "text-slate-400 text-sm")
  564. end unless attachment.respond_to?(:attached?) && attachment.attached?
  565. then: 0 else: 0 blob = attachment.is_a?(ActiveStorage::Attached::Many) ? attachment.first.blob : attachment.blob
  566. then: 0 if blob.image?
  567. content_tag(:div, class: "space-y-2") do
  568. # Image preview - full width in sidebar
  569. concat(content_tag(:div, class: "rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
  570. image_tag(attachment.variant(resize_to_limit: [ 400, 300 ]),
  571. class: "w-full h-auto object-cover",
  572. alt: blob.filename.to_s)
  573. end)
  574. # Compact metadata
  575. concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-500 dark:text-slate-400") do
  576. concat(content_tag(:span, number_to_human_size(blob.byte_size)))
  577. concat(link_to("View full", rails_blob_path(blob, disposition: :inline),
  578. target: "_blank",
  579. class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
  580. end)
  581. end
  582. else
  583. else: 0 # Non-image file in sidebar
  584. content_tag(:div, class: "flex items-center gap-2 p-2 bg-slate-50 dark:bg-slate-800 rounded-lg") do
  585. concat(content_tag(:div, class: "flex-shrink-0 w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded flex items-center justify-center") do
  586. '<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
  587. end)
  588. concat(content_tag(:div, class: "flex-1 min-w-0") do
  589. concat(content_tag(:p, blob.filename.to_s.truncate(20), class: "text-xs font-medium text-slate-700 dark:text-slate-300 truncate"))
  590. concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-xs text-slate-500"))
  591. end)
  592. end
  593. end
  594. end
  595. # Renders fields for main content (full layout)
  596. #
  597. # @param resource [ActiveRecord::Base] The record
  598. # @param fields [Array<Symbol>] Field names
  599. # @return [String] HTML safe fields
  600. 1 def render_main_fields(resource, fields)
  601. content_tag(:dl, class: "space-y-6") do
  602. fields.each do |field_name|
  603. concat(content_tag(:div) do
  604. concat(content_tag(:dt, field_name.to_s.humanize, class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
  605. concat(content_tag(:dd, class: "text-sm text-slate-900 dark:text-white") do
  606. format_show_value(resource, field_name)
  607. end)
  608. end)
  609. end
  610. end
  611. end
  612. # Renders an association section
  613. #
  614. # @param resource [ActiveRecord::Base] The record
  615. # @param section [ShowSectionDefinition] Section definition
  616. # @return [String] HTML safe association display
  617. 1 def render_association_section(resource, section)
  618. associated = resource.public_send(section.association) rescue nil
  619. then: 0 else: 0 return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if associated.nil?
  620. # Check if this is a belongs_to (single record) or has_many (collection)
  621. is_single = !associated.respond_to?(:to_a) || associated.is_a?(ActiveRecord::Base)
  622. else: 0 if is_single
  623. then: 0 # Single record (belongs_to)
  624. return render_association_card_single(associated, section)
  625. end
  626. items = associated
  627. pagy = nil
  628. then: 0 if section.paginate
  629. per_page = (section.per_page || section.limit || 20).to_i
  630. then: 0 else: 0 per_page = 1 if per_page < 1
  631. page_param = association_page_param(section)
  632. page = params[page_param].presence || 1
  633. then: 0 total_count = if associated.respond_to?(:count)
  634. associated.count
  635. else: 0 else
  636. associated.to_a.size
  637. end
  638. pagy = Pagy.new(count: total_count, page: page, limit: per_page, page_param: page_param)
  639. then: 0 if associated.respond_to?(:offset)
  640. items = associated.offset(pagy.offset).limit(per_page)
  641. else: 0 else
  642. items = Array.wrap(associated)[pagy.offset, per_page] || []
  643. else: 0 end
  644. then: 0 else: 0 elsif section.limit
  645. then: 0 if associated.respond_to?(:limit)
  646. items = associated.limit(section.limit)
  647. else: 0 else
  648. items = Array.wrap(associated).first(section.limit)
  649. end
  650. end
  651. items = Array.wrap(items)
  652. then: 0 else: 0 return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if items.empty?
  653. content_tag(:div) do
  654. case section.display
  655. when: 0 when :table
  656. concat(render_association_table(items, section))
  657. when: 0 when :cards
  658. concat(render_association_cards(items, section))
  659. else: 0 else
  660. concat(render_association_list(items, section))
  661. end
  662. then: 0 else: 0 concat(render_association_pagination(pagy)) if pagy
  663. end
  664. end
  665. 1 def association_page_param(section)
  666. "#{section.association}_page"
  667. end
  668. 1 def render_association_pagination(pagy)
  669. content_tag(:div, class: "-mx-6 border-t border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/30 px-6 py-3") do
  670. content_tag(:nav, class: "flex items-center justify-between", "aria-label" => "Pagination") do
  671. concat(pagy_prev_link(pagy))
  672. concat(pagy_page_links(pagy))
  673. concat(pagy_next_link(pagy))
  674. end
  675. end
  676. end
  677. 1 def pagy_prev_link(pagy)
  678. then: 0 if pagy.prev
  679. link_to("Prev", pagy_url_for(pagy, pagy.prev),
  680. class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
  681. else: 0 else
  682. content_tag(:span, "Prev",
  683. class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
  684. end
  685. end
  686. 1 def pagy_next_link(pagy)
  687. then: 0 if pagy.next
  688. link_to("Next", pagy_url_for(pagy, pagy.next),
  689. class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
  690. else: 0 else
  691. content_tag(:span, "Next",
  692. class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
  693. end
  694. end
  695. 1 def pagy_page_links(pagy)
  696. content_tag(:div, class: "flex items-center gap-1") do
  697. pagy.series.each do |item|
  698. concat(render_pagy_series_item(pagy, item))
  699. end
  700. end
  701. end
  702. 1 def render_pagy_series_item(pagy, item)
  703. case item
  704. when: 0 when Integer
  705. link_to(item, pagy_url_for(pagy, item),
  706. class: "px-2.5 py-1 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
  707. when: 0 when String
  708. content_tag(:span, item,
  709. class: "px-2.5 py-1 text-sm font-semibold text-white bg-indigo-600 border border-indigo-600 rounded")
  710. when: 0 when :gap
  711. content_tag(:span, "…", class: "px-2 text-sm text-slate-400 dark:text-slate-500")
  712. else: 0 else
  713. ""
  714. end
  715. end
  716. # Renders a single associated record (belongs_to)
  717. #
  718. # @param item [ActiveRecord::Base] The associated record
  719. # @param section [ShowSectionDefinition] Section definition
  720. # @return [String] HTML safe card
  721. 1 def render_association_card_single(item, section)
  722. link_path = build_association_link(item, section)
  723. card_content = capture do
  724. # Title row
  725. concat(content_tag(:div, class: "flex items-center justify-between gap-3") do
  726. concat(content_tag(:div, class: "min-w-0 flex-1") do
  727. title = item_display_title(item)
  728. then: 0 else: 0 title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
  729. concat(content_tag(:div, title, class: title_class))
  730. # Subtitle with extra info
  731. subtitle = []
  732. then: 0 else: 0 subtitle << item.status.to_s.humanize if item.respond_to?(:status) && item.status.present?
  733. then: 0 else: 0 subtitle << item.email_address if item.respond_to?(:email_address) && item.email_address.present?
  734. then: 0 else: 0 subtitle << item.tool_key if item.respond_to?(:tool_key) && item.tool_key.present?
  735. then: 0 else: 0 if subtitle.any?
  736. concat(content_tag(:div, subtitle.first, class: "text-sm text-slate-500 dark:text-slate-400 mt-0.5"))
  737. end
  738. end)
  739. then: 0 else: 0 if link_path
  740. concat('<svg class="w-5 h-5 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
  741. end
  742. end)
  743. end
  744. then: 0 if link_path
  745. link_to(card_content, link_path, class: "flex items-center -m-4 p-4 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/10 transition-colors group")
  746. else: 0 else
  747. content_tag(:div, card_content, class: "flex items-center")
  748. end
  749. end
  750. # Renders association as a list
  751. #
  752. # @param items [Array] Associated records
  753. # @param section [ShowSectionDefinition] Section definition
  754. # @return [String] HTML safe list
  755. 1 def render_association_list(items, section)
  756. content_tag(:div, class: "divide-y divide-slate-200 dark:divide-slate-700 -mx-6 -mt-2 -mb-6") do
  757. items.each do |item|
  758. link_path = build_association_link(item, section)
  759. then: 0 wrapper = if link_path
  760. ->(content) { link_to(link_path, class: "block px-6 py-4 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 transition-colors group") { content } }
  761. else: 0 else
  762. ->(content) { content_tag(:div, content, class: "px-6 py-4") }
  763. end
  764. concat(wrapper.call(capture do
  765. # Main row
  766. concat(content_tag(:div, class: "flex items-start justify-between gap-4") do
  767. # Left: Title and subtitle
  768. concat(content_tag(:div, class: "min-w-0 flex-1") do
  769. # Title with link indicator
  770. concat(content_tag(:div, class: "flex items-center gap-2") do
  771. title = item_display_title(item)
  772. then: 0 else: 0 title_class = link_path ? "text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "text-slate-900 dark:text-white"
  773. concat(content_tag(:span, title.truncate(60), class: "font-medium #{title_class} truncate"))
  774. then: 0 else: 0 if item.respond_to?(:status) && item.status.present?
  775. concat(render_status_badge(item.status, size: :sm))
  776. end
  777. end)
  778. # Subtitle with extra info
  779. subtitle_parts = []
  780. then: 0 else: 0 subtitle_parts << item.description.to_s.truncate(80) if item.respond_to?(:description) && item.description.present?
  781. then: 0 else: 0 subtitle_parts << item.tool_key if item.respond_to?(:tool_key) && item.tool_key.present?
  782. then: 0 else: 0 subtitle_parts << item.role.to_s.humanize if item.respond_to?(:role) && item.role.present?
  783. then: 0 else: 0 subtitle_parts << item.provider if item.respond_to?(:provider) && item.provider.present?
  784. then: 0 else: 0 if subtitle_parts.any?
  785. concat(content_tag(:p, subtitle_parts.first, class: "text-sm text-slate-500 dark:text-slate-400 mt-0.5 truncate"))
  786. end
  787. end)
  788. # Right: Meta info
  789. concat(content_tag(:div, class: "flex items-center gap-3 flex-shrink-0 text-xs text-slate-400") do
  790. # Type-specific badges
  791. then: 0 else: 0 if item.respond_to?(:active) || item.respond_to?(:active?)
  792. then: 0 else: 0 is_active = item.respond_to?(:active?) ? item.active? : item.active
  793. then: 0 else: 0 concat(content_tag(:span, is_active ? "Active" : "Inactive",
  794. then: 0 else: 0 class: is_active ? "px-1.5 py-0.5 rounded bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400" : "px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-700 text-slate-500"))
  795. end
  796. # Duration if available
  797. then: 0 if item.respond_to?(:duration_seconds) && item.duration_seconds.present?
  798. else: 0 concat(content_tag(:span, "#{item.duration_seconds.round(1)}s", class: "font-mono"))
  799. then: 0 else: 0 elsif item.respond_to?(:duration_ms) && item.duration_ms.present?
  800. concat(content_tag(:span, "#{item.duration_ms}ms", class: "font-mono"))
  801. end
  802. # Timestamp
  803. then: 0 else: 0 if item.respond_to?(:created_at) && item.created_at
  804. concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago"))
  805. end
  806. # Arrow indicator for links
  807. then: 0 else: 0 if link_path
  808. concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
  809. end
  810. end)
  811. end)
  812. end))
  813. end
  814. end
  815. end
  816. # Renders association as a table
  817. #
  818. # @param items [Array] Associated records
  819. # @param section [ShowSectionDefinition] Section definition
  820. # @return [String] HTML safe table
  821. 1 def render_association_table(items, section)
  822. resource_columns = association_resource_columns(section)
  823. # Smart column detection if not specified
  824. then: 0 columns = if section.columns.present?
  825. else: 0 section.columns.map { |col| resolve_association_column(col, resource_columns) }
  826. then: 0 elsif resource_columns.any?
  827. resource_columns
  828. else: 0 else
  829. detect_table_columns(items.first)
  830. end
  831. content_tag(:div, class: "overflow-x-auto -mx-6 -mt-1") do
  832. content_tag(:table, class: "min-w-full divide-y divide-slate-200 dark:divide-slate-700") do
  833. # Header
  834. concat(content_tag(:thead, class: "bg-slate-50/50 dark:bg-slate-900/30") do
  835. content_tag(:tr) do
  836. columns.each do |col|
  837. then: 0 else: 0 header = association_column_definition?(col) ? col.header : col.to_s.gsub(/_id$/, "").humanize
  838. concat(content_tag(:th, header, class: "px-4 py-2.5 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider first:pl-6"))
  839. end
  840. then: 0 else: 0 if section.link_to.present?
  841. concat(content_tag(:th, "", class: "px-4 py-2.5 w-16")) # Actions column
  842. end
  843. end
  844. end)
  845. # Body
  846. concat(content_tag(:tbody, class: "divide-y divide-slate-200 dark:divide-slate-700") do
  847. items.each do |item|
  848. link_path = build_association_link(item, section)
  849. then: 0 else: 0 row_class = link_path ? "hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 cursor-pointer group" : "hover:bg-slate-50 dark:hover:bg-slate-900/30"
  850. then: 0 else: 0 concat(content_tag(:tr, class: row_class, data: link_path ? { turbo_frame: "_top" } : {}) do
  851. columns.each_with_index do |col, idx|
  852. then: 0 else: 0 td_class = idx == 0 ? "px-4 py-3 text-sm first:pl-6" : "px-4 py-3 text-sm"
  853. then: 0 formatted = if association_column_definition?(col)
  854. render_association_column_value(item, col, section, link_path && idx == 0)
  855. else: 0 else
  856. value = item.public_send(col) rescue nil
  857. format_table_cell_enhanced(item, col, value, link_path && idx == 0)
  858. end
  859. concat(content_tag(:td, formatted, class: td_class))
  860. end
  861. # Actions
  862. then: 0 else: 0 if section.link_to.present? && link_path
  863. concat(content_tag(:td, class: "px-4 py-3 text-right pr-6") do
  864. link_to(link_path, class: "inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 text-sm font-medium") do
  865. "View".html_safe
  866. end
  867. end)
  868. end
  869. end)
  870. end
  871. end)
  872. end
  873. end
  874. end
  875. 1 def association_resource_columns(section)
  876. resource = section.resource
  877. else: 0 then: 0 return [] unless resource.respond_to?(:index_config)
  878. then: 0 else: 0 resource.index_config&.columns_list || []
  879. end
  880. 1 def association_column_definition?(column)
  881. column.is_a?(Admin::Base::Resource::ColumnDefinition)
  882. end
  883. 1 def resolve_association_column(column, resource_columns)
  884. else: 0 then: 0 return column unless column.is_a?(Symbol) || column.is_a?(String)
  885. resource_columns.find { |resource_column| resource_column.name.to_sym == column.to_sym } || column
  886. end
  887. 1 def render_association_column_value(item, column, section, is_primary)
  888. then: 0 else: 0 if column.type == :toggle
  889. field = (column.toggle_field || column.name).to_sym
  890. toggle_url = toggle_url_for_resource(section.resource, item, field)
  891. return render partial: "internal/developer/shared/toggle_cell",
  892. locals: { record: item, field: field, toggle_url: toggle_url }
  893. end
  894. then: 0 else: 0 if column.type == :label
  895. then: 0 else: 0 value = column.content.is_a?(Proc) ? column.content.call(item) : (item.public_send(column.name) rescue nil)
  896. return render_label_badge(value, color: column.label_color, size: column.label_size, record: item)
  897. end
  898. then: 0 value = if column.content.is_a?(Proc)
  899. column.content.call(item)
  900. else: 0 else
  901. item.public_send(column.name) rescue nil
  902. end
  903. format_table_cell_enhanced(item, column.name, value, is_primary)
  904. end
  905. 1 def toggle_url_for_resource(resource, record, field)
  906. then: 0 else: 0 else: 0 then: 0 return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
  907. internal_developer_resource_toggle_path(
  908. portal: resource.portal_name,
  909. resource_name: resource.resource_name_plural,
  910. id: record.id,
  911. field: field
  912. )
  913. rescue StandardError
  914. nil
  915. end
  916. # Detects appropriate columns for a table based on record attributes
  917. #
  918. # @param item [ActiveRecord::Base] Sample record
  919. # @return [Array<Symbol>] Column names
  920. 1 def detect_table_columns(item)
  921. else: 0 then: 0 return [ :id, :name, :created_at ] unless item
  922. # Priority columns to show
  923. priority = [ :name, :title, :status, :role, :tool_key, :provider, :model ]
  924. # Columns to skip
  925. skip = [ :id, :created_at, :updated_at, :password_digest, :encrypted_password ]
  926. attrs = item.attributes.keys.map(&:to_sym)
  927. # Start with priority columns that exist
  928. selected = priority.select { |c| attrs.include?(c) }
  929. # Add other relevant columns
  930. attrs.each do |col|
  931. then: 0 else: 0 next if skip.include?(col)
  932. then: 0 else: 0 next if selected.include?(col)
  933. then: 0 else: 0 next if col.to_s.end_with?("_id") # Skip foreign keys, show relations instead
  934. then: 0 else: 0 next if col.to_s.include?("token") || col.to_s.include?("secret")
  935. then: 0 else: 0 next if selected.size >= 5
  936. selected << col
  937. end
  938. # Always include created_at at the end if space
  939. then: 0 else: 0 selected << :created_at if selected.size < 5 && attrs.include?(:created_at)
  940. selected.take(5)
  941. end
  942. # Enhanced table cell formatting
  943. #
  944. # @param item [ActiveRecord::Base] The record
  945. # @param column [Symbol] Column name
  946. # @param value [Object] The value
  947. # @param is_primary [Boolean] Whether this is the primary/title column
  948. # @return [String] Formatted value
  949. 1 def format_table_cell_enhanced(item, column, value, is_primary = false)
  950. case value
  951. when: 0 when nil
  952. content_tag(:span, "—", class: "text-slate-400")
  953. when: 0 when true
  954. content_tag(:span, class: "inline-flex items-center gap-1") do
  955. '<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>'.html_safe
  956. end
  957. when: 0 when false
  958. content_tag(:span, class: "inline-flex items-center gap-1") do
  959. '<svg class="w-4 h-4 text-slate-300" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>'.html_safe
  960. end
  961. when: 0 when Time, DateTime
  962. content_tag(:span, value.strftime("%b %d, %H:%M"), class: "text-slate-600 dark:text-slate-400")
  963. when: 0 when Date
  964. content_tag(:span, value.strftime("%b %d, %Y"), class: "text-slate-600 dark:text-slate-400")
  965. when: 0 when Integer, Float, BigDecimal
  966. then: 0 if column.to_s.include?("duration") || column.to_s.include?("latency")
  967. else: 0 content_tag(:span, "#{value}ms", class: "font-mono text-slate-600 dark:text-slate-400")
  968. then: 0 elsif column.to_s.include?("cost") || column.to_s.include?("cents")
  969. else: 0 content_tag(:span, "$#{(value / 100.0).round(4)}", class: "font-mono text-slate-600 dark:text-slate-400")
  970. then: 0 elsif column.to_s.include?("token")
  971. content_tag(:span, number_with_delimiter(value), class: "font-mono text-slate-600 dark:text-slate-400")
  972. else: 0 else
  973. content_tag(:span, number_with_delimiter(value), class: "text-slate-900 dark:text-white")
  974. end
  975. when: 0 when ActiveRecord::Base
  976. then: 0 else: 0 display = value.respond_to?(:name) ? value.name : value.class.name.demodulize
  977. content_tag(:span, display.to_s.truncate(25), class: "text-slate-600 dark:text-slate-400")
  978. else: 0 else
  979. str = value.to_s
  980. then: 0 if column == :status || column.to_s.end_with?("_status")
  981. else: 0 render_status_badge(value, size: :sm)
  982. then: 0 elsif is_primary
  983. content_tag(:span, str.truncate(50), class: "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400")
  984. else: 0 else
  985. content_tag(:span, str.truncate(40), class: "text-slate-900 dark:text-white")
  986. end
  987. end
  988. end
  989. # Renders association as cards
  990. #
  991. # @param items [Array] Associated records
  992. # @param section [ShowSectionDefinition] Section definition
  993. # @return [String] HTML safe cards
  994. 1 def render_association_cards(items, section)
  995. content_tag(:div, class: "grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1") do
  996. items.each do |item|
  997. link_path = build_association_link(item, section)
  998. card_class = "border border-slate-200 dark:border-slate-700 rounded-lg p-4 transition-all"
  999. then: 0 else: 0 card_class += link_path ? " hover:border-indigo-300 dark:hover:border-indigo-700 hover:shadow-md group cursor-pointer" : " hover:bg-slate-50 dark:hover:bg-slate-900/30"
  1000. card_content = capture do
  1001. # Header with title and status
  1002. concat(content_tag(:div, class: "flex items-start justify-between gap-2 mb-2") do
  1003. title = item_display_title(item)
  1004. then: 0 else: 0 title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
  1005. concat(content_tag(:span, title.truncate(35), class: title_class))
  1006. then: 0 else: 0 if item.respond_to?(:status) && item.status.present?
  1007. concat(render_status_badge(item.status, size: :sm))
  1008. end
  1009. end)
  1010. # Description or key info
  1011. info_parts = []
  1012. then: 0 else: 0 info_parts << item.description.to_s.truncate(80) if item.respond_to?(:description) && item.description.present?
  1013. then: 0 else: 0 info_parts << "Tool: #{item.tool_key}" if item.respond_to?(:tool_key) && item.tool_key.present?
  1014. then: 0 else: 0 info_parts << "Role: #{item.role.to_s.humanize}" if item.respond_to?(:role) && item.role.present?
  1015. then: 0 else: 0 info_parts << "Provider: #{item.provider}" if item.respond_to?(:provider) && item.provider.present?
  1016. then: 0 else: 0 if info_parts.any?
  1017. concat(content_tag(:p, info_parts.first, class: "text-sm text-slate-500 dark:text-slate-400 mb-3 line-clamp-2"))
  1018. end
  1019. # Footer with meta info
  1020. concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-400 pt-2 border-t border-slate-100 dark:border-slate-700/50") do
  1021. # Left: timestamp
  1022. then: 0 else: 0 if item.respond_to?(:created_at) && item.created_at
  1023. concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago"))
  1024. end
  1025. # Right: additional info or link arrow
  1026. then: 0 if link_path
  1027. else: 0 concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
  1028. then: 0 else: 0 elsif item.respond_to?(:active) || item.respond_to?(:active?)
  1029. then: 0 else: 0 is_active = item.respond_to?(:active?) ? item.active? : item.active
  1030. then: 0 else: 0 concat(content_tag(:span, is_active ? "Active" : "Inactive",
  1031. then: 0 else: 0 class: is_active ? "text-green-600 dark:text-green-400" : "text-slate-400"))
  1032. end
  1033. end)
  1034. end
  1035. then: 0 if link_path
  1036. concat(link_to(card_content, link_path, class: card_class))
  1037. else: 0 else
  1038. concat(content_tag(:div, card_content, class: card_class))
  1039. end
  1040. end
  1041. end
  1042. end
  1043. # Formats a value for table cell display
  1044. #
  1045. # @param value [Object] The value
  1046. # @return [String] Formatted value
  1047. 1 def format_table_cell(value)
  1048. case value
  1049. when: 0 when nil
  1050. "—"
  1051. when: 0 when true, false
  1052. then: 0 else: 0 value ? "Yes" : "No"
  1053. when: 0 when Time, DateTime
  1054. value.strftime("%b %d, %H:%M")
  1055. when: 0 when Date
  1056. value.strftime("%b %d, %Y")
  1057. when: 0 when ActiveRecord::Base
  1058. item_display_title(value)
  1059. else: 0 else
  1060. value.to_s.truncate(50)
  1061. end
  1062. end
  1063. # Returns a display title for an item
  1064. #
  1065. # @param item [ActiveRecord::Base] The record
  1066. # @return [String] Display title
  1067. 1 def item_display_title(item)
  1068. then: 0 else: 0 return item.name if item.respond_to?(:name) && item.name.present?
  1069. then: 0 else: 0 return item.title if item.respond_to?(:title) && item.title.present?
  1070. then: 0 else: 0 return item.display_title if item.respond_to?(:display_title) && item.display_title.present?
  1071. then: 0 else: 0 return item.content.to_s.truncate(50) if item.respond_to?(:content)
  1072. then: 0 else: 0 return item.tool_key if item.respond_to?(:tool_key)
  1073. "##{item.id}"
  1074. end
  1075. # Builds a link path for an associated item
  1076. #
  1077. # @param item [ActiveRecord::Base] The record
  1078. # @param section [ShowSectionDefinition] Section definition
  1079. # @return [String, nil] Link path or nil
  1080. 1 def build_association_link(item, section)
  1081. then: 0 else: 0 if section.link_to.present?
  1082. begin
  1083. return send(section.link_to, item)
  1084. rescue NoMethodError
  1085. # fall through to auto-link
  1086. end
  1087. end
  1088. auto_internal_developer_path_for(item)
  1089. end
  1090. # Renders a status badge
  1091. #
  1092. # @param status [String, Symbol] The status
  1093. # @param size [Symbol] Badge size (:sm, :md)
  1094. # @return [String] HTML safe badge
  1095. 1 def render_status_badge(status, size: :md)
  1096. then: 0 else: 0 return content_tag(:span, "—", class: "text-slate-400") if status.blank?
  1097. status_str = status.to_s.downcase
  1098. colors = case status_str
  1099. when: 0 when "active", "open", "success", "approved", "completed", "enabled"
  1100. "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
  1101. when: 0 when "pending", "proposed", "queued", "waiting"
  1102. "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
  1103. when: 0 when "running", "processing", "in_progress"
  1104. "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
  1105. when: 0 when "error", "failed", "rejected", "cancelled"
  1106. "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
  1107. when: 0 when "inactive", "closed", "disabled", "archived"
  1108. "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
  1109. else: 0 else
  1110. "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
  1111. end
  1112. then: 0 else: 0 padding = size == :sm ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
  1113. content_tag(:span, status_str.titleize, class: "inline-flex items-center #{padding} rounded-full font-medium #{colors}")
  1114. end
  1115. 1 def render_label_badge(value, color: nil, size: :md, record: nil)
  1116. then: 0 else: 0 return content_tag(:span, "—", class: "text-slate-400") if value.blank?
  1117. label_color = resolve_label_option(color, record).presence || :slate
  1118. label_size = resolve_label_option(size, record).presence || :md
  1119. colors = label_badge_colors(label_color)
  1120. then: 0 else: 0 padding = label_size.to_s == "sm" ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
  1121. content_tag(:span, value.to_s, class: "inline-flex items-center #{padding} rounded-md font-medium #{colors}")
  1122. end
  1123. 1 def resolve_label_option(option, record)
  1124. then: 0 else: 0 return option.call(record) if option.is_a?(Proc)
  1125. option
  1126. end
  1127. 1 def label_badge_colors(color)
  1128. then: 0 if color.present?
  1129. "bg-#{color.to_s.downcase}-100 dark:bg-#{color.to_s.downcase}-900/30 text-#{color.to_s.downcase}-700 dark:text-#{color.to_s.downcase}-400"
  1130. else: 0 else
  1131. "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
  1132. end
  1133. end
  1134. # Renders a form field based on its configuration
  1135. #
  1136. # @param f [ActionView::Helpers::FormBuilder] Form builder
  1137. # @param field [FieldDefinition] Field definition
  1138. # @param resource [ActiveRecord::Base] The record
  1139. # @return [String] HTML safe form field
  1140. 1 def render_form_field(f, field, resource)
  1141. then: 0 else: 0 return if field.if_condition.present? && !field.if_condition.call(resource)
  1142. then: 0 else: 0 return if field.unless_condition.present? && field.unless_condition.call(resource)
  1143. capture do
  1144. concat(content_tag(:div, class: "form-group") do
  1145. concat(f.label(field.name, class: "form-label") do
  1146. concat(field.label)
  1147. then: 0 else: 0 concat(content_tag(:span, " *", class: "text-red-500")) if field.required
  1148. end)
  1149. field_class = "form-input w-full"
  1150. then: 0 else: 0 field_class += " border-red-500" if resource.errors[field.name].any?
  1151. field_html = case field.type
  1152. when: 0 when :textarea
  1153. f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
  1154. when: 0 when :url
  1155. f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  1156. when: 0 when :email
  1157. f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  1158. when: 0 when :number
  1159. f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  1160. when: 0 when :toggle
  1161. render_toggle_field(f, field, resource)
  1162. when: 0 when :label
  1163. label_value = resource.public_send(field.name) rescue nil
  1164. render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
  1165. when: 0 when :select
  1166. then: 0 else: 0 collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
  1167. f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
  1168. when: 0 when :searchable_select
  1169. render_searchable_select(f, field, resource)
  1170. when: 0 when :multi_select, :tags
  1171. render_multi_select(f, field, resource)
  1172. when: 0 when :image, :attachment
  1173. render_file_upload(f, field, resource)
  1174. when: 0 when :trix, :rich_text
  1175. f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
  1176. when: 0 when :markdown
  1177. f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12,
  1178. data: { controller: "markdown-editor" },
  1179. placeholder: field.placeholder)
  1180. when: 0 when :file
  1181. f.file_field(field.name, class: "form-input-file", accept: field.accept)
  1182. when: 0 when :datetime
  1183. f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
  1184. when: 0 when :date
  1185. f.date_field(field.name, class: field_class, readonly: field.readonly)
  1186. when: 0 when :time
  1187. f.time_field(field.name, class: field_class, readonly: field.readonly)
  1188. when: 0 when :json
  1189. render("internal/developer/shared/json_editor_field",
  1190. f: f,
  1191. field: field,
  1192. resource: resource)
  1193. when: 0 when :code
  1194. render_code_editor(f, field, resource)
  1195. else: 0 else
  1196. f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  1197. end
  1198. concat(field_html)
  1199. then: 0 else: 0 if field.help.present?
  1200. concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400"))
  1201. end
  1202. then: 0 else: 0 if resource.errors[field.name].any?
  1203. concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600 dark:text-red-400"))
  1204. end
  1205. end)
  1206. end
  1207. end
  1208. # Renders a toggle switch field (inline, not taking full width)
  1209. #
  1210. # @param f [ActionView::Helpers::FormBuilder] Form builder
  1211. # @param field [FieldDefinition] Field definition
  1212. # @param resource [ActiveRecord::Base] The record
  1213. # @return [String] HTML safe toggle switch
  1214. 1 def render_toggle_field(f, field, resource)
  1215. checked = !!resource.public_send(field.name)
  1216. param_key = resource.class.model_name.param_key
  1217. content_tag(:div, class: "inline-flex items-center gap-3",
  1218. data: { controller: "toggle-switch" }) do
  1219. # Toggle switch button
  1220. concat(content_tag(:button, type: "button",
  1221. then: 0 else: 0 class: "relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-800 #{checked ? 'bg-indigo-600' : 'bg-slate-200 dark:bg-slate-700'}",
  1222. role: "switch",
  1223. "aria-checked" => checked.to_s,
  1224. data: {
  1225. action: "click->toggle-switch#toggle",
  1226. toggle_switch_target: "button"
  1227. },
  1228. disabled: field.readonly) do
  1229. concat(content_tag(:span, "",
  1230. then: 0 else: 0 class: "pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out #{checked ? 'translate-x-5' : 'translate-x-0'}",
  1231. data: { toggle_switch_target: "thumb" }))
  1232. end)
  1233. # Hidden input for form submission
  1234. then: 0 else: 0 concat(hidden_field_tag("#{param_key}[#{field.name}]", checked ? "1" : "0",
  1235. id: "#{param_key}_#{field.name}",
  1236. data: { toggle_switch_target: "input" }))
  1237. # Status label
  1238. then: 0 else: 0 concat(content_tag(:span, checked ? "Enabled" : "Disabled",
  1239. class: "text-sm font-medium text-slate-700 dark:text-slate-300",
  1240. data: { toggle_switch_target: "label" }))
  1241. end
  1242. end
  1243. # Renders a searchable select field
  1244. #
  1245. # @param f [ActionView::Helpers::FormBuilder] Form builder
  1246. # @param field [FieldDefinition] Field definition
  1247. # @param resource [ActiveRecord::Base] The record
  1248. # @return [String] HTML safe searchable select
  1249. 1 def render_searchable_select(f, field, resource)
  1250. param_key = resource.class.model_name.param_key
  1251. current_value = resource.public_send(field.name)
  1252. # Get options - handle Proc, Array, or String (URL)
  1253. then: 0 else: 0 collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
  1254. then: 0 if collection.is_a?(Array)
  1255. options_json = collection.map { |opt|
  1256. then: 0 if opt.is_a?(Array)
  1257. { value: opt[1], label: opt[0] }
  1258. else: 0 else
  1259. { value: opt, label: opt.to_s.humanize }
  1260. end
  1261. }.to_json
  1262. else: 0 else
  1263. options_json = "[]"
  1264. end
  1265. # For display, find the current label
  1266. then: 0 current_label = if current_value.present? && collection.is_a?(Array)
  1267. then: 0 else: 0 match = collection.find { |opt| opt.is_a?(Array) ? opt[1].to_s == current_value.to_s : opt.to_s == current_value.to_s }
  1268. then: 0 else: 0 match.is_a?(Array) ? match[0] : match.to_s
  1269. else: 0 else
  1270. current_value
  1271. end
  1272. content_tag(:div,
  1273. data: {
  1274. controller: "searchable-select",
  1275. searchable_select_options_value: options_json,
  1276. searchable_select_creatable_value: field.create_url.present?,
  1277. then: 0 else: 0 searchable_select_search_url_value: collection.is_a?(String) ? collection : ""
  1278. },
  1279. class: "relative") do
  1280. concat(hidden_field_tag("#{param_key}[#{field.name}]", current_value,
  1281. data: { searchable_select_target: "input" }))
  1282. # IMPORTANT: this is a UI-only input. Do NOT submit it as a param.
  1283. # If we submit something like "#{param_key}[#{field.name}]_search", Rack may interpret it as
  1284. # nested under "#{param_key}[#{field.name}]" and error with:
  1285. # "expected Hash (got String) for param `#{field.name}`"
  1286. concat(text_field_tag(nil,
  1287. current_label,
  1288. class: "form-input w-full",
  1289. placeholder: field.placeholder || "Search...",
  1290. autocomplete: "off",
  1291. data: {
  1292. searchable_select_target: "search",
  1293. action: "input->searchable-select#search focus->searchable-select#open keydown->searchable-select#keydown"
  1294. }))
  1295. concat(content_tag(:div, "",
  1296. class: "absolute z-10 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg hidden max-h-60 overflow-y-auto",
  1297. data: { searchable_select_target: "dropdown" }))
  1298. end
  1299. end
  1300. # Renders a multi-select/tags field
  1301. #
  1302. # @param f [ActionView::Helpers::FormBuilder] Form builder
  1303. # @param field [FieldDefinition] Field definition
  1304. # @param resource [ActiveRecord::Base] The record
  1305. # @return [String] HTML safe multi-select
  1306. 1 def render_multi_select(f, field, resource)
  1307. param_key = resource.class.model_name.param_key
  1308. # Get current values
  1309. then: 0 current_values = if resource.respond_to?("#{field.name}_list")
  1310. else: 0 resource.public_send("#{field.name}_list")
  1311. then: 0 elsif resource.respond_to?(field.name)
  1312. Array.wrap(resource.public_send(field.name))
  1313. else: 0 else
  1314. []
  1315. end
  1316. # Get available options
  1317. then: 0 options = if field.collection.is_a?(Proc)
  1318. else: 0 field.collection.call
  1319. then: 0 elsif field.collection.is_a?(Array)
  1320. field.collection
  1321. else: 0 else
  1322. []
  1323. end
  1324. # For tags type, we need to use tag_list as the field name with array brackets
  1325. then: 0 else: 0 field_name = field.type == :tags ? "tag_list" : field.name
  1326. full_field_name = "#{param_key}[#{field_name}][]"
  1327. content_tag(:div,
  1328. data: {
  1329. controller: "tag-select",
  1330. tag_select_creatable_value: field.create_url.present? || field.type == :tags,
  1331. tag_select_field_name_value: full_field_name
  1332. },
  1333. class: "space-y-2") do
  1334. # Empty placeholder hidden field (will be replaced when tags are added)
  1335. concat(hidden_field_tag(full_field_name, "", id: nil, data: { tag_select_target: "placeholder" }))
  1336. # Selected tags display
  1337. concat(content_tag(:div,
  1338. class: "flex flex-wrap gap-2 min-h-[2.5rem] p-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg",
  1339. data: { tag_select_target: "tags" }) do
  1340. current_values.each do |val|
  1341. concat(content_tag(:span,
  1342. class: "inline-flex items-center gap-1 px-2 py-1 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 rounded text-sm") do
  1343. concat(val.to_s)
  1344. concat(hidden_field_tag(full_field_name, val, id: nil))
  1345. concat(button_tag("×",
  1346. type: "button",
  1347. class: "text-indigo-500 hover:text-indigo-700 font-bold",
  1348. data: { action: "tag-select#remove" }))
  1349. end)
  1350. end
  1351. # Input for adding new tags
  1352. concat(text_field_tag(nil, "",
  1353. class: "flex-1 min-w-[120px] border-none focus:outline-none focus:ring-0 bg-transparent text-sm",
  1354. placeholder: field.placeholder || "Add tag...",
  1355. autocomplete: "off",
  1356. data: {
  1357. tag_select_target: "input",
  1358. action: "keydown->tag-select#keydown input->tag-select#search"
  1359. }))
  1360. end)
  1361. # Suggestions dropdown
  1362. then: 0 else: 0 if options.any?
  1363. concat(content_tag(:div,
  1364. class: "hidden border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 shadow-lg max-h-48 overflow-y-auto",
  1365. data: { tag_select_target: "dropdown" }) do
  1366. options.each do |opt|
  1367. then: 0 else: 0 label, value = opt.is_a?(Array) ? [ opt[0], opt[1] ] : [ opt, opt ]
  1368. concat(content_tag(:button, label,
  1369. type: "button",
  1370. class: "block w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700",
  1371. data: {
  1372. action: "tag-select#select",
  1373. value: value
  1374. }))
  1375. end
  1376. end)
  1377. end
  1378. end
  1379. end
  1380. # Renders a file upload field with preview
  1381. #
  1382. # @param f [ActionView::Helpers::FormBuilder] Form builder
  1383. # @param field [FieldDefinition] Field definition
  1384. # @param resource [ActiveRecord::Base] The record
  1385. # @return [String] HTML safe file upload
  1386. 1 def render_file_upload(f, field, resource)
  1387. then: 0 else: 0 attachment = resource.respond_to?(field.name) ? resource.public_send(field.name) : nil
  1388. has_attachment = attachment.respond_to?(:attached?) && attachment.attached?
  1389. is_image = field.type == :image ||
  1390. (field.accept.present? && field.accept.include?("image"))
  1391. # Build existing URL for preview if available
  1392. then: 0 else: 0 existing_url = has_attachment && is_image ? url_for(attachment.variant(resize_to_limit: [ 300, 300 ])) : nil
  1393. content_tag(:div,
  1394. data: {
  1395. controller: "file-upload",
  1396. then: 0 else: 0 file_upload_accept_value: field.accept || (is_image ? "image/*" : "*/*"),
  1397. file_upload_preview_value: field.type == :image,
  1398. file_upload_existing_url_value: existing_url
  1399. },
  1400. class: "space-y-3") do
  1401. # Current file preview
  1402. then: 0 if has_attachment && is_image
  1403. concat(content_tag(:div, class: "relative inline-block") do
  1404. concat(image_tag(existing_url,
  1405. class: "max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover",
  1406. data: { file_upload_target: "imagePreview" }))
  1407. concat(button_tag("×", type: "button",
  1408. class: "absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center text-sm",
  1409. data: {
  1410. file_upload_target: "removeButton",
  1411. action: "file-upload#remove"
  1412. }))
  1413. else: 0 end)
  1414. then: 0 elsif has_attachment
  1415. concat(content_tag(:div,
  1416. class: "flex items-center gap-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg",
  1417. data: { file_upload_target: "filename" }) do
  1418. concat('<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>'.html_safe)
  1419. concat(content_tag(:span, attachment.filename.to_s, class: "text-sm font-medium text-slate-600 dark:text-slate-300"))
  1420. concat(content_tag(:span, "(#{number_to_human_size(attachment.byte_size)})", class: "text-xs text-slate-400"))
  1421. end)
  1422. else
  1423. else: 0 # Hidden image preview for new uploads
  1424. concat(image_tag("",
  1425. class: "hidden max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover",
  1426. data: { file_upload_target: "imagePreview" }))
  1427. concat(content_tag(:div, "",
  1428. class: "hidden",
  1429. data: { file_upload_target: "filename" }))
  1430. end
  1431. # Dropzone / Upload area
  1432. concat(content_tag(:div,
  1433. class: "relative border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg hover:border-indigo-400 dark:hover:border-indigo-500 transition-colors",
  1434. data: { file_upload_target: "dropzone" }) do
  1435. # Hidden file input
  1436. concat(f.file_field(field.name,
  1437. class: "sr-only",
  1438. id: "#{field.name}_input",
  1439. then: 0 else: 0 accept: field.accept || (is_image ? "image/*" : nil),
  1440. data: {
  1441. file_upload_target: "input",
  1442. action: "change->file-upload#preview"
  1443. }))
  1444. # Styled upload label
  1445. concat(content_tag(:label,
  1446. for: "#{field.name}_input",
  1447. class: "flex flex-col items-center justify-center w-full py-6 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-900/50 rounded-lg transition-colors") do
  1448. concat('<svg class="w-8 h-8 text-slate-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>'.html_safe)
  1449. concat(content_tag(:span, "Click to upload or drag and drop", class: "text-sm text-slate-500 dark:text-slate-400"))
  1450. then: 0 if is_image
  1451. else: 0 concat(content_tag(:span, "PNG, JPG, WebP up to 10MB", class: "text-xs text-slate-400 mt-1"))
  1452. then: 0 else: 0 elsif field.accept.present?
  1453. concat(content_tag(:span, field.accept.gsub(",", ", "), class: "text-xs text-slate-400 mt-1"))
  1454. end
  1455. end)
  1456. end)
  1457. # Progress indicator (hidden by default)
  1458. concat(content_tag(:div, "",
  1459. class: "hidden",
  1460. data: { file_upload_target: "progress" }))
  1461. end
  1462. end
  1463. # Renders turn messages preview (for assistant turns)
  1464. #
  1465. # @param resource [ActiveRecord::Base] The turn record
  1466. # @return [String] HTML safe messages preview
  1467. 1 def render_turn_messages_preview(resource)
  1468. then: 0 else: 0 user_msg = resource.respond_to?(:user_message) ? resource.user_message : nil
  1469. then: 0 else: 0 asst_msg = resource.respond_to?(:assistant_message) ? resource.assistant_message : nil
  1470. content_tag(:div, class: "space-y-4") do
  1471. # User message
  1472. then: 0 else: 0 if user_msg
  1473. concat(content_tag(:div, class: "rounded-lg border p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800") do
  1474. concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
  1475. concat('<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe)
  1476. concat(content_tag(:span, "User", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
  1477. end)
  1478. concat(content_tag(:div, simple_format(h(user_msg.content.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
  1479. end)
  1480. end
  1481. # Assistant message
  1482. then: 0 else: 0 if asst_msg
  1483. concat(content_tag(:div, class: "rounded-lg border p-4 bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800") do
  1484. concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
  1485. concat('<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe)
  1486. concat(content_tag(:span, "Assistant", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
  1487. end)
  1488. concat(content_tag(:div, simple_format(h(asst_msg.content.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
  1489. end)
  1490. end
  1491. else: 0 then: 0 unless user_msg || asst_msg
  1492. concat(content_tag(:p, "No messages found", class: "text-slate-400 italic text-sm"))
  1493. end
  1494. end
  1495. end
  1496. # Renders a code editor field
  1497. #
  1498. # @param f [ActionView::Helpers::FormBuilder] Form builder
  1499. # @param field [FieldDefinition] Field definition
  1500. # @param resource [ActiveRecord::Base] The record
  1501. # @return [String] HTML safe code editor
  1502. 1 def render_code_editor(f, field, resource)
  1503. content_tag(:div, class: "relative", data: { controller: "code-editor" }) do
  1504. f.text_area(field.name,
  1505. class: "w-full font-mono text-sm bg-slate-900 text-slate-100 p-4 rounded-lg border border-slate-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500",
  1506. rows: field.rows || 12,
  1507. placeholder: field.placeholder,
  1508. data: { code_editor_target: "textarea" })
  1509. end
  1510. end
  1511. end
  1512. end
  1513. end

app/helpers/internal/developer/custom_renderers_helper.rb

14.71% lines covered

0.0% branches covered

34 relevant lines. 5 lines covered and 29 lines missed.
16 total branches, 0 branches covered and 16 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Internal
  3. 1 module Developer
  4. # App-specific renderers for the internal developer portal.
  5. #
  6. # These are intentionally kept out of the core admin suite helper so the
  7. # suite can be extracted into a reusable engine/gem.
  8. 1 module CustomRenderersHelper
  9. # Renders a billing debug snapshot for a user record.
  10. #
  11. # Intended for the internal developer portal.
  12. #
  13. # @param resource [ActiveRecord::Base] Expected to be a User
  14. # @return [String]
  15. 1 def render_billing_debug_snapshot(resource)
  16. else: 0 then: 0 unless resource.is_a?(User)
  17. return content_tag(:p, "Billing debug snapshot is only supported for User records.", class: "text-slate-500 italic text-sm")
  18. end
  19. snapshot = Billing::DebugSnapshotService.new(user: resource).run
  20. render_json_block(snapshot)
  21. rescue StandardError => e
  22. content_tag(:div, class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4") do
  23. concat(content_tag(:p, "Failed to build billing debug snapshot.", class: "text-sm font-medium text-red-700 dark:text-red-300"))
  24. concat(content_tag(:p, e.message.to_s, class: "mt-1 text-sm text-red-600 dark:text-red-400 font-mono"))
  25. end
  26. end
  27. # Renders provider-native payloads for Ai::LlmApiLog in a prominent, copy-friendly format.
  28. #
  29. # Reads from:
  30. # - request_payload["provider_request"]
  31. # - response_payload["provider_response"] / ["provider_error_response"]
  32. #
  33. # @param resource [ActiveRecord::Base]
  34. # @param kind [Symbol] :provider_request, :provider_response, :provider_error_response
  35. # @return [String]
  36. 1 def render_llm_provider_payload(resource, kind:)
  37. else: 0 then: 0 return content_tag(:p, "Not available", class: "text-slate-400 italic text-sm") unless resource.respond_to?(:request_payload) && resource.respond_to?(:response_payload)
  38. request_payload = resource.request_payload || {}
  39. response_payload = resource.response_payload || {}
  40. value =
  41. case kind.to_sym
  42. when: 0 when :provider_request
  43. request_payload["provider_request"] || request_payload[:provider_request]
  44. when: 0 when :provider_response
  45. response_payload["provider_response"] || response_payload[:provider_response]
  46. when: 0 when :provider_error_response
  47. response_payload["provider_error_response"] || response_payload[:provider_error_response]
  48. else: 0 else
  49. nil
  50. end
  51. then: 0 else: 0 if value.blank?
  52. hint =
  53. then: 0 if kind.to_sym == :provider_error_response
  54. "No provider error response captured."
  55. else: 0 else
  56. "No provider payload captured."
  57. end
  58. return content_tag(:div, class: "text-sm text-slate-500 dark:text-slate-400") do
  59. concat(content_tag(:p, hint, class: "italic"))
  60. concat(content_tag(:p, "Older logs (or synthetic logs) may not include raw provider payloads.", class: "text-xs mt-1"))
  61. end
  62. end
  63. # Render hashes/arrays as highlighted JSON. Strings are attempted as JSON, else plain.
  64. then: 0 if value.is_a?(Hash) || value.is_a?(Array)
  65. render_json_block(value)
  66. else: 0 else
  67. str = value.to_s
  68. then: 0 if str.strip.start_with?("{", "[")
  69. begin
  70. render_json_block(JSON.parse(str))
  71. rescue JSON::ParserError
  72. render_text_block(str)
  73. end
  74. else: 0 else
  75. render_text_block(str)
  76. end
  77. end
  78. end
  79. end
  80. end
  81. end

app/helpers/internal/developer/dashboard_helper.rb

10.32% lines covered

0.0% branches covered

155 relevant lines. 16 lines covered and 139 lines missed.
80 total branches, 0 branches covered and 80 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Internal
  3. 1 module Developer
  4. # Helper methods for dashboard cards and health metrics
  5. 1 module DashboardHelper
  6. # Renders a stat card
  7. #
  8. # @param label [String] The label text
  9. # @param value [String, Integer] The value to display
  10. # @param options [Hash] Additional options
  11. # @option options [Symbol] :color Color theme (:default, :green, :red, :amber, :cyan, :violet)
  12. # @option options [String] :subtitle Additional text below the value
  13. # @option options [String] :trend Trend indicator (e.g., "+5%", "-2%")
  14. # @option options [Symbol] :trend_direction :up, :down, or :neutral
  15. # @return [String] HTML for the stat card
  16. 1 def stat_card(label, value, **options)
  17. color = options[:color] || :default
  18. color_classes = stat_color_classes(color)
  19. content_tag(:div, class: "#{color_classes[:bg]} rounded-xl p-5 #{color_classes[:border]}") do
  20. concat(content_tag(:div, class: "flex items-center justify-between") do
  21. concat(content_tag(:div, value.to_s, class: "text-3xl font-bold #{color_classes[:text]}"))
  22. then: 0 else: 0 if options[:trend].present?
  23. when: 0 trend_class = case options[:trend_direction]
  24. when: 0 when :up then "text-green-500"
  25. else: 0 when :down then "text-red-500"
  26. else "text-slate-400"
  27. end
  28. concat(content_tag(:span, options[:trend], class: "text-sm font-medium #{trend_class}"))
  29. end
  30. end)
  31. concat(content_tag(:div, label, class: "text-sm #{color_classes[:label]} mt-1"))
  32. then: 0 else: 0 if options[:subtitle].present?
  33. concat(content_tag(:div, options[:subtitle], class: "text-xs #{color_classes[:label]} mt-1 opacity-75"))
  34. end
  35. end
  36. end
  37. # Renders a health status card
  38. #
  39. # @param title [String] The system name
  40. # @param status [Symbol] :healthy, :degraded, :critical, or :unknown
  41. # @param metrics [Hash] Key-value pairs of metrics to display
  42. # @return [String] HTML for the health card
  43. 1 def health_card(title, status, metrics: {})
  44. status_config = health_status_config(status)
  45. content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border #{status_config[:border]} overflow-hidden") do
  46. # Header
  47. concat(content_tag(:div, class: "px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between") do
  48. concat(content_tag(:h3, title, class: "font-semibold text-slate-900 dark:text-white"))
  49. concat(content_tag(:span, class: "flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium #{status_config[:badge]}") do
  50. concat(content_tag(:span, "", class: "w-2 h-2 rounded-full #{status_config[:dot]}"))
  51. concat(status.to_s.humanize)
  52. end)
  53. end)
  54. # Metrics
  55. then: 0 else: 0 if metrics.any?
  56. concat(content_tag(:div, class: "p-4 grid grid-cols-2 gap-3") do
  57. metrics.each do |key, val|
  58. concat(content_tag(:div) do
  59. concat(content_tag(:div, val.to_s, class: "text-lg font-semibold text-slate-900 dark:text-white"))
  60. concat(content_tag(:div, key.to_s.humanize, class: "text-xs text-slate-500 dark:text-slate-400"))
  61. end)
  62. end
  63. end)
  64. end
  65. end
  66. end
  67. # Renders a recent items list card
  68. #
  69. # @param title [String] Card title
  70. # @param items [Array] Array of records to display
  71. # @param options [Hash] Display options
  72. # @option options [String] :path_helper Helper method name for item links
  73. # @option options [Symbol] :title_field Field to use for item title
  74. # @option options [Symbol] :subtitle_field Field to use for subtitle
  75. # @option options [Symbol] :badge_field Field to use for status badge
  76. # @option options [String] :empty_message Message when no items
  77. # @option options [String] :view_all_path Link to view all items
  78. # @return [String] HTML for the recent items card
  79. 1 def recent_items_card(title, items, **options)
  80. content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
  81. # Header
  82. concat(content_tag(:div, class: "px-4 py-3 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between") do
  83. concat(content_tag(:h3, title, class: "font-semibold text-slate-900 dark:text-white"))
  84. then: 0 else: 0 if options[:view_all_path].present?
  85. concat(link_to("View all →", options[:view_all_path],
  86. class: "text-sm text-indigo-600 dark:text-indigo-400 hover:underline"))
  87. end
  88. end)
  89. # Items list
  90. then: 0 if items.any?
  91. concat(content_tag(:ul, class: "divide-y divide-slate-100 dark:divide-slate-700") do
  92. items.each do |item|
  93. concat(render_recent_item(item, options))
  94. end
  95. end)
  96. else: 0 else
  97. concat(content_tag(:div, class: "p-4 text-center text-sm text-slate-500 dark:text-slate-400") do
  98. options[:empty_message] || "No recent items"
  99. end)
  100. end
  101. end
  102. end
  103. # Renders a mini chart card (sparkline-style)
  104. #
  105. # @param title [String] Card title
  106. # @param data [Array<Hash>] Array of { label:, value: } hashes
  107. # @param options [Hash] Display options
  108. # @option options [Symbol] :color Color theme
  109. # @option options [String] :total Total value to display
  110. # @return [String] HTML for the chart card
  111. 1 def chart_card(title, data, **options)
  112. max_value = data.map { |d| d[:value].to_f }.max || 1
  113. then: 0 else: 0 max_value = 1 if max_value.zero? # Avoid division by zero
  114. bar_color = chart_bar_color(options[:color])
  115. content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4") do
  116. # Header
  117. concat(content_tag(:div, class: "flex items-center justify-between mb-4") do
  118. concat(content_tag(:h3, title, class: "font-semibold text-slate-900 dark:text-white"))
  119. then: 0 else: 0 if options[:total].present?
  120. concat(content_tag(:span, options[:total], class: "text-2xl font-bold text-slate-900 dark:text-white"))
  121. end
  122. end)
  123. # Bar chart
  124. concat(content_tag(:div, class: "flex items-end gap-1 h-16") do
  125. data.each do |d|
  126. value = d[:value].to_f
  127. then: 0 else: 0 height_float = max_value > 0 ? ((value / max_value) * 100) : 0.0
  128. # Check for NaN or infinite before rounding
  129. then: 0 else: 0 height_float = 0.0 if height_float.nan? || height_float.infinite?
  130. height = height_float.round
  131. concat(content_tag(:div, class: "flex-1 flex flex-col items-center gap-1") do
  132. concat(content_tag(:div, "",
  133. class: "w-full rounded-t #{bar_color} transition-all",
  134. style: "height: #{height}%",
  135. title: "#{d[:label]}: #{d[:value]}"))
  136. end)
  137. end
  138. end)
  139. # Labels
  140. concat(content_tag(:div, class: "flex gap-1 mt-2") do
  141. data.each do |d|
  142. concat(content_tag(:div, d[:label].to_s.first(3),
  143. class: "flex-1 text-center text-xs text-slate-400 dark:text-slate-500"))
  144. end
  145. end)
  146. end
  147. end
  148. # Renders an activity timeline card
  149. #
  150. # @param title [String] Card title
  151. # @param activities [Array<Hash>] Array of { title:, time:, icon:, color: }
  152. # @return [String] HTML for the activity card
  153. 1 def activity_card(title, activities)
  154. content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
  155. concat(content_tag(:div, class: "px-4 py-3 border-b border-slate-200 dark:border-slate-700") do
  156. content_tag(:h3, title, class: "font-semibold text-slate-900 dark:text-white")
  157. end)
  158. then: 0 if activities.any?
  159. concat(content_tag(:div, class: "p-4 space-y-4") do
  160. activities.each do |activity|
  161. concat(render_activity_item(activity))
  162. end
  163. end)
  164. else: 0 else
  165. concat(content_tag(:div, class: "p-4 text-center text-sm text-slate-500 dark:text-slate-400") do
  166. "No recent activity"
  167. end)
  168. end
  169. end
  170. end
  171. 1 private
  172. 1 def stat_color_classes(color)
  173. case color
  174. when: 0 when :green
  175. {
  176. bg: "bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20",
  177. border: "border border-green-200 dark:border-green-800/50",
  178. text: "text-green-700 dark:text-green-400",
  179. label: "text-green-600 dark:text-green-500"
  180. }
  181. when: 0 when :red
  182. {
  183. bg: "bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/20 dark:to-red-800/20",
  184. border: "border border-red-200 dark:border-red-800/50",
  185. text: "text-red-700 dark:text-red-400",
  186. label: "text-red-600 dark:text-red-500"
  187. }
  188. when: 0 when :amber
  189. {
  190. bg: "bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-900/20 dark:to-amber-800/20",
  191. border: "border border-amber-200 dark:border-amber-800/50",
  192. text: "text-amber-700 dark:text-amber-400",
  193. label: "text-amber-600 dark:text-amber-500"
  194. }
  195. when: 0 when :cyan
  196. {
  197. bg: "bg-gradient-to-br from-cyan-50 to-cyan-100 dark:from-cyan-900/20 dark:to-cyan-800/20",
  198. border: "border border-cyan-200 dark:border-cyan-800/50",
  199. text: "text-cyan-700 dark:text-cyan-400",
  200. label: "text-cyan-600 dark:text-cyan-500"
  201. }
  202. when: 0 when :violet
  203. {
  204. bg: "bg-gradient-to-br from-violet-50 to-violet-100 dark:from-violet-900/20 dark:to-violet-800/20",
  205. border: "border border-violet-200 dark:border-violet-800/50",
  206. text: "text-violet-700 dark:text-violet-400",
  207. label: "text-violet-600 dark:text-violet-500"
  208. }
  209. else: 0 else
  210. {
  211. bg: "bg-white dark:bg-slate-800",
  212. border: "border border-slate-200 dark:border-slate-700",
  213. text: "text-slate-900 dark:text-white",
  214. label: "text-slate-500 dark:text-slate-400"
  215. }
  216. end
  217. end
  218. 1 def health_status_config(status)
  219. case status
  220. when: 0 when :healthy
  221. {
  222. border: "border-green-200 dark:border-green-800/50",
  223. badge: "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400",
  224. dot: "bg-green-500 animate-pulse"
  225. }
  226. when: 0 when :degraded
  227. {
  228. border: "border-amber-200 dark:border-amber-800/50",
  229. badge: "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400",
  230. dot: "bg-amber-500 animate-pulse"
  231. }
  232. when: 0 when :critical
  233. {
  234. border: "border-red-200 dark:border-red-800/50",
  235. badge: "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400",
  236. dot: "bg-red-500 animate-pulse"
  237. }
  238. else: 0 else
  239. {
  240. border: "border-slate-200 dark:border-slate-700",
  241. badge: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400",
  242. dot: "bg-slate-400"
  243. }
  244. end
  245. end
  246. 1 def render_recent_item(item, options)
  247. title_value = item.public_send(options[:title_field] || :id)
  248. # Handle ActiveRecord associations - try to get a display name
  249. then: 0 title = if title_value.respond_to?(:name)
  250. else: 0 title_value.name
  251. then: 0 elsif title_value.respond_to?(:title)
  252. else: 0 title_value.title
  253. then: 0 elsif title_value.respond_to?(:email_address)
  254. title_value.email_address
  255. else: 0 else
  256. title_value.to_s
  257. end
  258. then: 0 else: 0 subtitle_value = options[:subtitle_field] ? item.public_send(options[:subtitle_field]) : nil
  259. then: 0 subtitle = if subtitle_value.respond_to?(:name)
  260. else: 0 subtitle_value.name
  261. then: 0 elsif subtitle_value.respond_to?(:title)
  262. else: 0 subtitle_value.title
  263. then: 0 elsif subtitle_value.respond_to?(:email_address)
  264. subtitle_value.email_address
  265. else: 0 else
  266. then: 0 else: 0 subtitle_value&.to_s
  267. end
  268. then: 0 else: 0 badge = options[:badge_field] ? item.public_send(options[:badge_field]) : nil
  269. then: 0 path = if options[:path_helper]
  270. then: 0 if options[:path_helper].respond_to?(:call)
  271. options[:path_helper].call(item)
  272. else: 0 else
  273. send(options[:path_helper], item)
  274. end
  275. else: 0 else
  276. nil
  277. end
  278. content_tag(:li, class: "px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors") do
  279. then: 0 else: 0 wrapper = path ? link_to(path, class: "block") : content_tag(:div)
  280. then: 0 if path
  281. link_to(path, class: "flex items-center justify-between") do
  282. concat(render_recent_item_content(title, subtitle, item))
  283. then: 0 else: 0 if badge
  284. concat(content_tag(:span, badge.to_s.humanize,
  285. class: "px-2 py-1 text-xs rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"))
  286. end
  287. end
  288. else: 0 else
  289. content_tag(:div, class: "flex items-center justify-between") do
  290. concat(render_recent_item_content(title, subtitle, item))
  291. then: 0 else: 0 if badge
  292. concat(content_tag(:span, badge.to_s.humanize,
  293. class: "px-2 py-1 text-xs rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"))
  294. end
  295. end
  296. end
  297. end
  298. end
  299. 1 def render_recent_item_content(title, subtitle, item)
  300. content_tag(:div) do
  301. concat(content_tag(:div, title.to_s.truncate(40), class: "text-sm font-medium text-slate-900 dark:text-white"))
  302. then: 0 if subtitle.present?
  303. else: 0 concat(content_tag(:div, subtitle.to_s, class: "text-xs text-slate-500 dark:text-slate-400"))
  304. then: 0 else: 0 elsif item.respond_to?(:created_at)
  305. concat(content_tag(:div, time_ago_in_words(item.created_at) + " ago",
  306. class: "text-xs text-slate-500 dark:text-slate-400"))
  307. end
  308. end
  309. end
  310. 1 def render_activity_item(activity)
  311. icon_classes = activity_icon_classes(activity[:color])
  312. content_tag(:div, class: "flex gap-3") do
  313. concat(content_tag(:div, class: "flex-shrink-0 w-8 h-8 rounded-full #{icon_classes[:bg]} flex items-center justify-center") do
  314. content_tag(:span, activity[:icon] || "•", class: icon_classes[:text])
  315. end)
  316. concat(content_tag(:div, class: "flex-1 min-w-0") do
  317. concat(content_tag(:p, activity[:title], class: "text-sm text-slate-900 dark:text-white"))
  318. concat(content_tag(:p, activity[:time], class: "text-xs text-slate-500 dark:text-slate-400"))
  319. end)
  320. end
  321. end
  322. 1 def chart_bar_color(color)
  323. when: 0 case color
  324. when: 0 when :amber then "bg-amber-500 dark:bg-amber-400"
  325. when: 0 when :green then "bg-green-500 dark:bg-green-400"
  326. when: 0 when :red then "bg-red-500 dark:bg-red-400"
  327. when: 0 when :cyan then "bg-cyan-500 dark:bg-cyan-400"
  328. when: 0 when :violet then "bg-violet-500 dark:bg-violet-400"
  329. else: 0 when :indigo then "bg-indigo-500 dark:bg-indigo-400"
  330. else "bg-indigo-500 dark:bg-indigo-400"
  331. end
  332. end
  333. 1 def activity_icon_classes(color)
  334. case color
  335. when: 0 when :green
  336. { bg: "bg-green-100 dark:bg-green-900/30", text: "text-green-600 dark:text-green-400" }
  337. when: 0 when :red
  338. { bg: "bg-red-100 dark:bg-red-900/30", text: "text-red-600 dark:text-red-400" }
  339. when: 0 when :amber
  340. { bg: "bg-amber-100 dark:bg-amber-900/30", text: "text-amber-600 dark:text-amber-400" }
  341. when: 0 when :cyan
  342. { bg: "bg-cyan-100 dark:bg-cyan-900/30", text: "text-cyan-600 dark:text-cyan-400" }
  343. when: 0 when :violet
  344. { bg: "bg-violet-100 dark:bg-violet-900/30", text: "text-violet-600 dark:text-violet-400" }
  345. else: 0 else
  346. { bg: "bg-slate-100 dark:bg-slate-700", text: "text-slate-600 dark:text-slate-400" }
  347. end
  348. end
  349. end
  350. end
  351. end

app/helpers/interview_applications_helper.rb

20.0% lines covered

0.0% branches covered

65 relevant lines. 13 lines covered and 52 lines missed.
44 total branches, 0 branches covered and 44 branches missed.
    
  1. # frozen_string_literal: true
  2. # Helper methods for interview application views
  3. #
  4. # Provides consistent styling classes for badges, status indicators,
  5. # and other UI elements across all interview application views.
  6. 1 module InterviewApplicationsHelper
  7. # Event type icons (SVG paths)
  8. 1 EVENT_ICONS = {
  9. applied: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
  10. interview: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z",
  11. interview_scheduled: "M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z",
  12. interview_completed: "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
  13. feedback: "M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z",
  14. feedback_received: "M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z",
  15. email: "M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z",
  16. offer: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
  17. rejection: "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z",
  18. rejected: "M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z",
  19. status_change: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
  20. default: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
  21. }.freeze
  22. # Returns styling data for timeline events
  23. #
  24. # @param event_type [String, Symbol] The event type
  25. # @return [Hash] Hash containing :icon and :classes for various elements
  26. 1 def timeline_event_styling(event_type)
  27. type = event_type.to_s.to_sym
  28. {
  29. icon: EVENT_ICONS[type] || EVENT_ICONS[:default],
  30. classes: timeline_event_classes(type)
  31. }
  32. end
  33. # Returns Tailwind classes for pipeline stage badges
  34. #
  35. # @param stage [String, Symbol] The pipeline stage
  36. # @return [String] Tailwind CSS classes
  37. 1 def pipeline_stage_badge_classes(stage)
  38. then: 0 else: 0 case stage&.to_sym
  39. when: 0 when :applied
  40. "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
  41. when: 0 when :screening
  42. "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
  43. when: 0 when :interviewing
  44. "bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400"
  45. when: 0 when :offer
  46. "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
  47. when: 0 when :closed
  48. "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
  49. else: 0 else
  50. "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
  51. end
  52. end
  53. # Returns Tailwind classes for application status badges
  54. #
  55. # @param status [String, Symbol, nil] The application status
  56. # @return [String] Tailwind CSS classes
  57. 1 def application_status_badge_classes(status)
  58. then: 0 else: 0 case status&.to_sym
  59. when: 0 when :active
  60. "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
  61. when: 0 when :accepted
  62. "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
  63. when: 0 when :rejected
  64. "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
  65. when: 0 when :archived
  66. "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
  67. else: 0 else
  68. "bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400"
  69. end
  70. end
  71. # Returns styling data for match score display
  72. #
  73. # Used in prep_snapshot to style the match score card based on
  74. # the fit assessment score.
  75. #
  76. # @param score [Integer, nil] The fit assessment score (0-100)
  77. # @return [Hash] Hash containing :label, :color, and CSS class mappings
  78. 1 def match_score_styling(score)
  79. label, color = match_score_label_and_color(score)
  80. {
  81. label: label,
  82. color: color,
  83. classes: match_score_classes_for_color(color)
  84. }
  85. end
  86. # Returns just the label and color for a match score
  87. #
  88. # @param score [Integer, nil] The fit assessment score (0-100)
  89. # @return [Array<String, String>] [label, color]
  90. 1 def match_score_label_and_color(score)
  91. then: 0 if score.nil?
  92. else: 0 [ "Not assessed", "slate" ]
  93. then: 0 elsif score >= 80
  94. else: 0 [ "Strong match", "emerald" ]
  95. then: 0 elsif score >= 50
  96. [ "Partial match", "amber" ]
  97. else: 0 else
  98. [ "Stretch role", "rose" ]
  99. end
  100. end
  101. # Returns available pipeline stage transitions for an application
  102. #
  103. # Checks the AASM state machine to determine which transitions
  104. # are valid from the current state.
  105. #
  106. # @param application [InterviewApplication] The application to check
  107. # @return [Array<Hash>] Array of hashes with :stage and :label keys
  108. 1 def available_pipeline_stage_transitions(application)
  109. InterviewApplication::PIPELINE_STAGES.filter_map do |stage|
  110. then: 0 else: 0 next if stage.to_s == application.pipeline_stage
  111. event = pipeline_stage_event_for(stage)
  112. else: 0 then: 0 next unless event && application.aasm(:pipeline_stage).may_fire_event?(event)
  113. { stage: stage, label: stage.to_s.titleize }
  114. end
  115. end
  116. # Maps a pipeline stage to its corresponding AASM event
  117. #
  118. # @param stage [Symbol] The target pipeline stage
  119. # @return [Symbol, nil] The AASM event name or nil if not found
  120. 1 def pipeline_stage_event_for(stage)
  121. {
  122. screening: :move_to_screening,
  123. interviewing: :move_to_interviewing,
  124. offer: :move_to_offer,
  125. closed: :move_to_closed,
  126. applied: :move_to_applied
  127. }[stage]
  128. end
  129. # Returns icon color class for a pipeline stage
  130. #
  131. # Used for styling icons in the pipeline stage actions menu.
  132. #
  133. # @param stage [Symbol] The pipeline stage
  134. # @return [String] Tailwind CSS color class
  135. 1 def pipeline_stage_icon_color(stage)
  136. case stage
  137. when: 0 when :applied
  138. "text-gray-500"
  139. when: 0 when :screening
  140. "text-blue-500"
  141. when: 0 when :interviewing
  142. "text-purple-500"
  143. when: 0 when :offer
  144. "text-green-500"
  145. when: 0 when :closed
  146. "text-gray-500"
  147. else: 0 else
  148. "text-gray-500"
  149. end
  150. end
  151. 1 private
  152. # Returns CSS class mappings for a timeline event type
  153. #
  154. # All classes are explicitly written out to ensure Tailwind
  155. # can detect them at build time (no dynamic string interpolation).
  156. #
  157. # @param event_type [Symbol] The event type
  158. # @return [Hash] CSS class mappings for various elements
  159. 1 def timeline_event_classes(event_type)
  160. case event_type
  161. when: 0 when :applied
  162. {
  163. dot_bg: "bg-blue-100 dark:bg-blue-900/40",
  164. dot_border: "border-blue-500",
  165. accent: "bg-blue-500",
  166. icon_bg: "bg-blue-100 dark:bg-blue-900/30",
  167. icon_text: "text-blue-600 dark:text-blue-400",
  168. badge_bg: "bg-blue-100 dark:bg-blue-900/30",
  169. badge_text: "text-blue-700 dark:text-blue-300"
  170. }
  171. when: 0 when :interview, :interview_scheduled
  172. {
  173. dot_bg: "bg-violet-100 dark:bg-violet-900/40",
  174. dot_border: "border-violet-500",
  175. accent: "bg-violet-500",
  176. icon_bg: "bg-violet-100 dark:bg-violet-900/30",
  177. icon_text: "text-violet-600 dark:text-violet-400",
  178. badge_bg: "bg-violet-100 dark:bg-violet-900/30",
  179. badge_text: "text-violet-700 dark:text-violet-300"
  180. }
  181. when: 0 when :interview_completed
  182. {
  183. dot_bg: "bg-emerald-100 dark:bg-emerald-900/40",
  184. dot_border: "border-emerald-500",
  185. accent: "bg-emerald-500",
  186. icon_bg: "bg-emerald-100 dark:bg-emerald-900/30",
  187. icon_text: "text-emerald-600 dark:text-emerald-400",
  188. badge_bg: "bg-emerald-100 dark:bg-emerald-900/30",
  189. badge_text: "text-emerald-700 dark:text-emerald-300"
  190. }
  191. when: 0 when :feedback, :feedback_received
  192. {
  193. dot_bg: "bg-amber-100 dark:bg-amber-900/40",
  194. dot_border: "border-amber-500",
  195. accent: "bg-amber-500",
  196. icon_bg: "bg-amber-100 dark:bg-amber-900/30",
  197. icon_text: "text-amber-600 dark:text-amber-400",
  198. badge_bg: "bg-amber-100 dark:bg-amber-900/30",
  199. badge_text: "text-amber-700 dark:text-amber-300"
  200. }
  201. when: 0 when :email
  202. {
  203. dot_bg: "bg-cyan-100 dark:bg-cyan-900/40",
  204. dot_border: "border-cyan-500",
  205. accent: "bg-cyan-500",
  206. icon_bg: "bg-cyan-100 dark:bg-cyan-900/30",
  207. icon_text: "text-cyan-600 dark:text-cyan-400",
  208. badge_bg: "bg-cyan-100 dark:bg-cyan-900/30",
  209. badge_text: "text-cyan-700 dark:text-cyan-300"
  210. }
  211. when: 0 when :offer
  212. {
  213. dot_bg: "bg-emerald-100 dark:bg-emerald-900/40",
  214. dot_border: "border-emerald-500",
  215. accent: "bg-emerald-500",
  216. icon_bg: "bg-emerald-100 dark:bg-emerald-900/30",
  217. icon_text: "text-emerald-600 dark:text-emerald-400",
  218. badge_bg: "bg-emerald-100 dark:bg-emerald-900/30",
  219. badge_text: "text-emerald-700 dark:text-emerald-300"
  220. }
  221. when: 0 when :rejection, :rejected
  222. {
  223. dot_bg: "bg-rose-100 dark:bg-rose-900/40",
  224. dot_border: "border-rose-500",
  225. accent: "bg-rose-500",
  226. icon_bg: "bg-rose-100 dark:bg-rose-900/30",
  227. icon_text: "text-rose-600 dark:text-rose-400",
  228. badge_bg: "bg-rose-100 dark:bg-rose-900/30",
  229. badge_text: "text-rose-700 dark:text-rose-300"
  230. }
  231. when: 0 when :status_change
  232. {
  233. dot_bg: "bg-indigo-100 dark:bg-indigo-900/40",
  234. dot_border: "border-indigo-500",
  235. accent: "bg-indigo-500",
  236. icon_bg: "bg-indigo-100 dark:bg-indigo-900/30",
  237. icon_text: "text-indigo-600 dark:text-indigo-400",
  238. badge_bg: "bg-indigo-100 dark:bg-indigo-900/30",
  239. badge_text: "text-indigo-700 dark:text-indigo-300"
  240. }
  241. else: 0 else # gray/default
  242. {
  243. dot_bg: "bg-gray-100 dark:bg-gray-700",
  244. dot_border: "border-gray-400 dark:border-gray-500",
  245. accent: "bg-gray-400 dark:bg-gray-500",
  246. icon_bg: "bg-gray-100 dark:bg-gray-700",
  247. icon_text: "text-gray-600 dark:text-gray-400",
  248. badge_bg: "bg-gray-100 dark:bg-gray-700",
  249. badge_text: "text-gray-700 dark:text-gray-300"
  250. }
  251. end
  252. end
  253. # Returns CSS class mappings for a given match color
  254. #
  255. # All classes are explicitly written out to ensure Tailwind
  256. # can detect them at build time (no dynamic string interpolation).
  257. #
  258. # @param color [String] The color name (emerald, amber, rose, slate)
  259. # @return [Hash] CSS class mappings for various elements
  260. 1 def match_score_classes_for_color(color)
  261. case color
  262. when: 0 when "emerald"
  263. {
  264. gradient: "from-emerald-100 dark:from-emerald-900/20",
  265. icon_bg: "bg-emerald-100 dark:bg-emerald-500/20",
  266. icon_text: "text-emerald-600 dark:text-emerald-300",
  267. badge_bg: "bg-emerald-100 dark:bg-emerald-900/30",
  268. badge_text: "text-emerald-700 dark:text-emerald-300",
  269. dot: "bg-emerald-500"
  270. }
  271. when: 0 when "amber"
  272. {
  273. gradient: "from-amber-100 dark:from-amber-900/20",
  274. icon_bg: "bg-amber-100 dark:bg-amber-500/20",
  275. icon_text: "text-amber-600 dark:text-amber-300",
  276. badge_bg: "bg-amber-100 dark:bg-amber-900/30",
  277. badge_text: "text-amber-700 dark:text-amber-300",
  278. dot: "bg-amber-500"
  279. }
  280. when: 0 when "rose"
  281. {
  282. gradient: "from-rose-100 dark:from-rose-900/20",
  283. icon_bg: "bg-rose-100 dark:bg-rose-500/20",
  284. icon_text: "text-rose-600 dark:text-rose-300",
  285. badge_bg: "bg-rose-100 dark:bg-rose-900/30",
  286. badge_text: "text-rose-700 dark:text-rose-300",
  287. dot: "bg-rose-500"
  288. }
  289. else: 0 else # slate (Not assessed)
  290. {
  291. gradient: "from-slate-200 dark:from-slate-600/20",
  292. icon_bg: "bg-slate-200 dark:bg-slate-500/30",
  293. icon_text: "text-slate-600 dark:text-slate-300",
  294. badge_bg: "bg-slate-200 dark:bg-slate-600/30",
  295. badge_text: "text-slate-700 dark:text-slate-300",
  296. dot: "bg-slate-400 dark:bg-slate-400"
  297. }
  298. end
  299. end
  300. end

app/helpers/pagy_helper.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Helper module for Pagy pagination
  3. #
  4. # Provides access to Pagy frontend helpers in views
  5. 1 module PagyHelper
  6. 1 include Pagy::Frontend
  7. end

app/helpers/text_formatter_helper.rb

15.0% lines covered

0.0% branches covered

200 relevant lines. 30 lines covered and 170 lines missed.
88 total branches, 0 branches covered and 88 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "cgi"
  3. # Helper for formatting extracted text content (descriptions, requirements, etc.)
  4. #
  5. # Provides smart formatting that:
  6. # - Detects and renders bullet points as lists
  7. # - Detects and renders numbered lists
  8. # - Detects implicit lists (lines that look like list items)
  9. # - Detects and formats hash/array data structures
  10. # - Preserves paragraphs
  11. # - Supports basic markdown rendering
  12. # - Sanitizes HTML for security
  13. #
  14. # @example
  15. # <%= format_job_text(@job_listing.description) %>
  16. 1 module TextFormatterHelper
  17. # Patterns to detect Ruby hash/array syntax
  18. 1 HASH_PATTERN = /\A\s*\{.*=>\s*.*\}\s*\z/m
  19. 1 ARRAY_PATTERN = /\A\s*\[.*\]\s*\z/m
  20. 1 HASH_ARROW_PATTERN = /["\']([^"\']+)["\']\s*=>\s*/
  21. # Patterns that indicate a line is likely a list item even without bullet markers
  22. 1 IMPLICIT_LIST_PATTERNS = [
  23. /^You'll\s/i, # "You'll be responsible for..."
  24. /^You\s+will\s/i, # "You will design..."
  25. /^We're\s+looking/i, # "We're looking for..."
  26. /^Must\s+have/i, # "Must have experience..."
  27. /^Should\s+have/i, # "Should have knowledge..."
  28. /^Experience\s+(with|in)/i, # "Experience with..."
  29. /^Strong\s/i, # "Strong communication skills"
  30. /^Excellent\s/i, # "Excellent problem solving"
  31. /^Ability\s+to/i, # "Ability to work..."
  32. /^Knowledge\s+of/i, # "Knowledge of..."
  33. /^Familiarity\s+with/i, # "Familiarity with..."
  34. /^Proficiency\s+in/i, # "Proficiency in..."
  35. /^Understanding\s+of/i, # "Understanding of..."
  36. /^Proven\s/i, # "Proven track record..."
  37. /^Deep\s+expertise/i, # "Deep expertise in..."
  38. /^\d+\+?\s*years?/i, # "5+ years experience"
  39. /^Bachelor'?s?\s/i, # "Bachelor's degree"
  40. /^Master'?s?\s/i, # "Master's degree"
  41. /^PhD\s/i, # "PhD in..."
  42. /^Build\s/i, # "Build scalable systems"
  43. /^Design\s/i, # "Design and implement..."
  44. /^Develop\s/i, # "Develop new features"
  45. /^Lead\s/i, # "Lead a team of..."
  46. /^Manage\s/i, # "Manage projects..."
  47. /^Work\s+(with|closely)/i, # "Work with cross-functional teams"
  48. /^Collaborate\s/i, # "Collaborate with..."
  49. /^Own\s/i, # "Own the entire..."
  50. /^Drive\s/i # "Drive technical decisions"
  51. ].freeze
  52. # Formats job listing text with smart detection and markdown support
  53. #
  54. # @param [String] text The raw text to format
  55. # @param [Hash] options Formatting options
  56. # @option options [Boolean] :markdown Enable markdown parsing (default: true)
  57. # @option options [Boolean] :detect_lists Auto-detect bullet/numbered lists (default: true)
  58. # @option options [Boolean] :linkify Convert URLs to links (default: true)
  59. # @return [String] Formatted HTML
  60. 1 def format_job_text(text, options = {})
  61. then: 0 else: 0 return "" if text.blank?
  62. options = {
  63. markdown: true,
  64. detect_lists: true,
  65. detect_implicit_lists: true,
  66. linkify: true
  67. }.merge(options)
  68. formatted = text.to_s.dup
  69. # Some sources (notably Greenhouse boards API) return HTML as escaped entities
  70. # (e.g., "&lt;p&gt;...&lt;/p&gt;"). Decode first so we can sanitize+render properly.
  71. then: 0 else: 0 if formatted.include?("&lt;") && formatted.include?("&gt;")
  72. begin
  73. formatted = CGI.unescapeHTML(formatted)
  74. rescue
  75. nil
  76. end
  77. end
  78. # If this is already HTML (and not markdown-like), keep structure and just sanitize.
  79. # Note: some markdown content may contain inline HTML (e.g. <strong> inside list items).
  80. then: 0 else: 0 if formatted.match?(%r{</?(p|ul|ol|li|h2|h3|h4|div|span|strong|em|br)\b}i) && !looks_like_markdown?(formatted)
  81. return sanitize(formatted, tags: allowed_tags, attributes: allowed_attributes)
  82. end
  83. # First, check if this looks like a Ruby hash or array
  84. then: 0 if looks_like_ruby_data?(formatted)
  85. formatted = format_ruby_data(formatted)
  86. else: 0 # Next, try to detect if it looks like markdown
  87. then: 0 elsif options[:markdown] && looks_like_markdown?(formatted)
  88. formatted = render_markdown(formatted)
  89. else
  90. else: 0 # Convert detected lists to HTML (including implicit lists)
  91. then: 0 else: 0 if options[:detect_lists]
  92. formatted = convert_lists_to_html(formatted, detect_implicit: options[:detect_implicit_lists])
  93. end
  94. formatted = convert_paragraphs_to_html(formatted)
  95. end
  96. # Convert URLs to links
  97. then: 0 else: 0 if options[:linkify]
  98. formatted = linkify_urls(formatted)
  99. end
  100. # Sanitize and return
  101. sanitize(formatted, tags: allowed_tags, attributes: allowed_attributes)
  102. end
  103. # Formats text specifically for requirements/responsibilities lists
  104. # More aggressive about detecting list items
  105. #
  106. # @param [String] text The raw text
  107. # @return [String] Formatted HTML
  108. 1 def format_list_text(text)
  109. then: 0 else: 0 return "" if text.blank?
  110. formatted = text.to_s.dup
  111. # Always try to detect lists for requirements/responsibilities
  112. then: 0 if looks_like_markdown?(formatted)
  113. formatted = render_markdown(formatted)
  114. else
  115. else: 0 # Use aggressive implicit list detection for requirements/responsibilities
  116. formatted = convert_lists_to_html(formatted, detect_implicit: true, force_list: true)
  117. formatted = convert_paragraphs_to_html(formatted)
  118. end
  119. sanitize(formatted, tags: allowed_tags, attributes: allowed_attributes)
  120. end
  121. # Formats key-value pairs for display (e.g., "Contract Type: B2B")
  122. #
  123. # @param [String] text Text containing key-value pairs
  124. # @return [String] Formatted HTML with styled key-value display
  125. 1 def format_key_value_text(text)
  126. then: 0 else: 0 return "" if text.blank?
  127. lines = text.to_s.strip.split("\n").map(&:strip).reject(&:blank?)
  128. # Check if this looks like key-value data
  129. kv_pairs = lines.map do |line|
  130. then: 0 if line.match?(/^([^:]+):\s*(.+)$/)
  131. match = line.match(/^([^:]+):\s*(.+)$/)
  132. { key: match[1].strip, value: match[2].strip }
  133. else: 0 else
  134. { text: line }
  135. end
  136. end
  137. # If most lines are key-value pairs, render as definition list
  138. kv_count = kv_pairs.count { |p| p[:key] }
  139. if kv_count >= (lines.count * 0.5) && kv_count >= 2
  140. then: 0 # Sanitize the rendered key-value list HTML
  141. sanitize(render_key_value_list(kv_pairs), tags: allowed_tags, attributes: allowed_attributes)
  142. else
  143. else: 0 # Fall back to regular list formatting
  144. format_list_text(text)
  145. end
  146. end
  147. # Checks if text appears to contain markdown formatting
  148. #
  149. # @param [String] text The text to check
  150. # @return [Boolean] True if markdown-like
  151. 1 def looks_like_markdown?(text)
  152. then: 0 else: 0 return false if text.blank?
  153. # Check for common markdown patterns
  154. markdown_patterns = [
  155. /^#+\s/m, # Headers
  156. /^\s*[-*+]\s+/m, # Unordered lists
  157. /^\s*\d+\.\s+/m, # Ordered lists
  158. /\*\*[^*]+\*\*/, # Bold
  159. /\*[^*]+\*/, # Italic
  160. /`[^`]+`/, # Code
  161. /\[[^\]]+\]\([^)]+\)/ # Links
  162. ]
  163. markdown_patterns.any? { |pattern| text.match?(pattern) }
  164. end
  165. # Checks if text looks like Ruby hash or array syntax
  166. #
  167. # @param [String] text The text to check
  168. # @return [Boolean] True if it looks like Ruby data
  169. 1 def looks_like_ruby_data?(text)
  170. then: 0 else: 0 return false if text.blank?
  171. stripped = text.to_s.strip
  172. # Check for hash arrow syntax {"key" => "value"} or symbol keys {:key => "value"}
  173. then: 0 else: 0 return true if stripped.match?(HASH_PATTERN) && stripped.include?("=>")
  174. # Check for array of hashes [{...}, {...}]
  175. then: 0 else: 0 return true if stripped.match?(ARRAY_PATTERN) && stripped.include?("=>")
  176. false
  177. end
  178. # Formats Ruby hash/array data into readable HTML
  179. #
  180. # @param [String] text The Ruby data string
  181. # @return [String] Formatted HTML
  182. 1 def format_ruby_data(text)
  183. stripped = text.to_s.strip
  184. begin
  185. # Try to safely parse the Ruby-like data
  186. parsed = parse_ruby_data(stripped)
  187. render_parsed_data(parsed)
  188. rescue StandardError => e
  189. Rails.logger.warn("Failed to parse Ruby data: #{e.message}")
  190. # Fall back to basic formatting if parsing fails
  191. format_ruby_data_fallback(stripped)
  192. end
  193. end
  194. # Parses Ruby-like hash/array syntax
  195. #
  196. # @param [String] text The text to parse
  197. # @return [Hash, Array, String] Parsed data
  198. 1 def parse_ruby_data(text)
  199. # Replace Ruby hash rockets with JSON-like syntax for parsing
  200. # Convert "key" => "value" to "key": "value"
  201. json_like = text.dup
  202. # Handle symbol keys like :key => to "key":
  203. json_like.gsub!(/:(\w+)\s*=>/, '"\1":')
  204. # Handle string keys like "key" => to "key":
  205. json_like.gsub!(/["']([^"']+)["']\s*=>/, '"\1":')
  206. # Try to parse as JSON
  207. JSON.parse(json_like)
  208. rescue JSON::ParserError
  209. # If JSON parsing fails, return the original text
  210. text
  211. end
  212. # Renders parsed data into HTML
  213. #
  214. # @param [Object] data The parsed data
  215. # @param [Integer] depth Current nesting depth
  216. # @return [String] HTML output
  217. 1 def render_parsed_data(data, depth = 0)
  218. case data
  219. when: 0 when Hash
  220. render_hash_data(data, depth)
  221. when: 0 when Array
  222. then: 0 if data.first.is_a?(Hash)
  223. render_array_of_hashes(data, depth)
  224. else: 0 else
  225. render_simple_array(data, depth)
  226. end
  227. else: 0 else
  228. "<p class=\"text-gray-600 dark:text-gray-400\">#{ERB::Util.html_escape(data.to_s)}</p>"
  229. end
  230. end
  231. # Renders a hash as a definition list
  232. #
  233. # @param [Hash] hash The hash to render
  234. # @param [Integer] depth Current depth
  235. # @return [String] HTML
  236. 1 def render_hash_data(hash, depth = 0)
  237. items = hash.map do |key, value|
  238. formatted_key = humanize_key(key.to_s)
  239. formatted_value = format_hash_value(value, depth)
  240. <<~HTML
  241. <div class="flex flex-col sm:flex-row sm:gap-2 py-1.5">
  242. <dt class="text-sm font-medium text-gray-700 dark:text-gray-300 sm:min-w-[140px]">#{ERB::Util.html_escape(formatted_key)}</dt>
  243. <dd class="text-sm text-gray-600 dark:text-gray-400">#{formatted_value}</dd>
  244. </div>
  245. HTML
  246. end.join
  247. "<dl class=\"divide-y divide-gray-100 dark:divide-gray-700\">#{items}</dl>"
  248. end
  249. # Formats a hash value for display
  250. #
  251. # @param [Object] value The value to format
  252. # @param [Integer] depth Current depth
  253. # @return [String] Formatted HTML
  254. 1 def format_hash_value(value, depth)
  255. case value
  256. when: 0 when Array
  257. if value.all? { |v| v.is_a?(String) || v.is_a?(Numeric) }
  258. then: 0 # Simple array of values - render as comma-separated or pills
  259. pills = value.map do |v|
  260. "<span class=\"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 mr-1 mb-1\">#{ERB::Util.html_escape(v)}</span>"
  261. end.join
  262. "<div class=\"flex flex-wrap\">#{pills}</div>"
  263. else: 0 else
  264. render_parsed_data(value, depth + 1)
  265. end
  266. when: 0 when Hash
  267. render_parsed_data(value, depth + 1)
  268. else: 0 else
  269. ERB::Util.html_escape(value.to_s)
  270. end
  271. end
  272. # Renders an array of hashes (like recruitment process steps)
  273. #
  274. # @param [Array<Hash>] array The array of hashes
  275. # @param [Integer] depth Current depth
  276. # @return [String] HTML
  277. 1 def render_array_of_hashes(array, depth = 0)
  278. items = array.map.with_index do |item, index|
  279. # Try to find a name/title/step key for the header
  280. header = item["name"] || item["title"] || item["step"] || "Item #{index + 1}"
  281. then: 0 else: 0 header = "Step #{item['step']}: #{item['name']}" if item["step"] && item["name"]
  282. details = item.except("name", "title", "step").map do |key, value|
  283. formatted_key = humanize_key(key.to_s)
  284. then: 0 else: 0 formatted_value = value.is_a?(Array) ? value.join(", ") : value.to_s
  285. "<span class=\"text-gray-500 dark:text-gray-400\">#{ERB::Util.html_escape(formatted_key)}:</span> #{ERB::Util.html_escape(formatted_value)}"
  286. end.join(" • ")
  287. <<~HTML
  288. <li class="py-2">
  289. <div class="font-medium text-gray-800 dark:text-gray-200">#{ERB::Util.html_escape(header)}</div>
  290. <div class="text-sm text-gray-600 dark:text-gray-400 mt-0.5">#{details}</div>
  291. </li>
  292. HTML
  293. end.join
  294. "<ol class=\"divide-y divide-gray-100 dark:divide-gray-700 list-none\">#{items}</ol>"
  295. end
  296. # Renders a simple array as a list
  297. #
  298. # @param [Array] array The array to render
  299. # @param [Integer] depth Current depth
  300. # @return [String] HTML
  301. 1 def render_simple_array(array, depth = 0)
  302. items = array.map do |item|
  303. "<li class=\"py-1\">#{ERB::Util.html_escape(item.to_s)}</li>"
  304. end.join
  305. "<ul class=\"list-disc list-inside space-y-1 text-gray-600 dark:text-gray-400\">#{items}</ul>"
  306. end
  307. # Humanizes a snake_case or camelCase key
  308. #
  309. # @param [String] key The key to humanize
  310. # @return [String] Humanized key
  311. 1 def humanize_key(key)
  312. key.to_s
  313. .gsub(/([a-z])([A-Z])/, '\1 \2') # Split camelCase
  314. .gsub("_", " ") # Split snake_case
  315. .titleize
  316. end
  317. # Fallback formatting for Ruby data that couldn't be parsed
  318. #
  319. # @param [String] text The original text
  320. # @return [String] Formatted HTML
  321. 1 def format_ruby_data_fallback(text)
  322. # Try to extract key-value pairs from the string representation
  323. pairs = []
  324. text.scan(/["']([^"']+)["']\s*=>\s*["']?([^"',}\]]+)["']?/) do |key, value|
  325. pairs << { key: humanize_key(key), value: value.strip }
  326. end
  327. then: 0 if pairs.any?
  328. items = pairs.map do |pair|
  329. <<~HTML
  330. <div class="flex flex-col sm:flex-row sm:gap-2 py-1">
  331. <dt class="text-sm font-medium text-gray-700 dark:text-gray-300 sm:min-w-[140px]">#{ERB::Util.html_escape(pair[:key])}</dt>
  332. <dd class="text-sm text-gray-600 dark:text-gray-400">#{ERB::Util.html_escape(pair[:value])}</dd>
  333. </div>
  334. HTML
  335. end.join
  336. "<dl class=\"divide-y divide-gray-100 dark:divide-gray-700\">#{items}</dl>"
  337. else
  338. else: 0 # Just show the raw text with better formatting
  339. "<p class=\"text-gray-600 dark:text-gray-400 font-mono text-sm\">#{ERB::Util.html_escape(text)}</p>"
  340. end
  341. end
  342. 1 private
  343. # Renders key-value pairs as a styled definition list
  344. #
  345. # @param [Array<Hash>] pairs Array of key-value pairs
  346. # @return [String] HTML
  347. 1 def render_key_value_list(pairs)
  348. items = pairs.map do |pair|
  349. then: 0 if pair[:key]
  350. <<~HTML
  351. <div class="flex flex-col sm:flex-row sm:gap-2 py-1">
  352. <dt class="text-sm font-medium text-gray-700 dark:text-gray-300 sm:min-w-[140px]">#{ERB::Util.html_escape(pair[:key])}</dt>
  353. <dd class="text-sm text-gray-600 dark:text-gray-400">#{ERB::Util.html_escape(pair[:value])}</dd>
  354. </div>
  355. HTML
  356. else: 0 else
  357. "<p class=\"text-sm text-gray-600 dark:text-gray-400 py-1\">#{ERB::Util.html_escape(pair[:text])}</p>"
  358. end
  359. end.join
  360. "<dl class=\"divide-y divide-gray-100 dark:divide-gray-700\">#{items}</dl>"
  361. end
  362. # Renders markdown to HTML using a simple parser
  363. #
  364. # Outputs plain HTML elements without inline classes - styling is handled
  365. # by the parent container's CSS class (e.g., job-prose, doc-content).
  366. #
  367. # @param [String] text The markdown text
  368. # @return [String] HTML output
  369. 1 def render_markdown(text)
  370. html = text.dup
  371. # Horizontal rules (---, ***, ___) - use [^\n]* to avoid crossing lines
  372. html.gsub!(/^[^\S\n]*(---|\*\*\*|___)[^\S\n]*$/, "<hr>")
  373. # Blockquotes - use [^\n]+ to match only within a single line
  374. html.gsub!(/^[^\S\n]*>[^\S\n]*([^\n]+)$/, '<blockquote>\1</blockquote>')
  375. # Headers - use [^\n]+ to prevent matching across lines
  376. html.gsub!(/^####[^\S\n]+([^\n]+)$/, '<h5>\1</h5>')
  377. html.gsub!(/^###[^\S\n]+([^\n]+)$/, '<h4>\1</h4>')
  378. html.gsub!(/^##[^\S\n]+([^\n]+)$/, '<h3>\1</h3>')
  379. html.gsub!(/^#[^\S\n]+([^\n]+)$/, '<h2>\1</h2>')
  380. # Bold and italic (non-greedy matching)
  381. html.gsub!(/\*\*([^*\n]+?)\*\*/, '<strong>\1</strong>')
  382. html.gsub!(/\*([^*\n]+?)\*/, '<em>\1</em>')
  383. # Inline code
  384. html.gsub!(/`([^`\n]+?)`/, '<code>\1</code>')
  385. # Convert lists and paragraphs
  386. html = convert_lists_to_html(html, detect_implicit: true)
  387. html = convert_paragraphs_to_html(html)
  388. html
  389. end
  390. # Converts detected bullet and numbered lists to HTML
  391. # Also detects implicit lists (lines that look like list items)
  392. #
  393. # @param [String] text The text
  394. # @param [Boolean] detect_implicit Whether to detect implicit list items
  395. # @param [Boolean] force_list Whether to force list rendering for newline-separated items
  396. # @return [String] Text with HTML lists
  397. 1 def convert_lists_to_html(text, detect_implicit: false, force_list: false)
  398. lines = text.split("\n")
  399. result = []
  400. current_list_type = nil
  401. list_items = []
  402. # Check if we should force list mode (multiple short lines that look like items)
  403. then: 0 else: 0 if force_list
  404. non_empty_lines = lines.map(&:strip).reject(&:blank?)
  405. if non_empty_lines.length >= 3 && non_empty_lines.all? { |l| l.length < 200 }
  406. then: 0 # Check if most lines look like list items
  407. implicit_count = non_empty_lines.count { |l| looks_like_implicit_list_item?(l) }
  408. force_list = implicit_count >= (non_empty_lines.length * 0.5)
  409. else: 0 else
  410. force_list = false
  411. end
  412. end
  413. lines.each do |line|
  414. stripped = line.strip
  415. # Detect explicit bullet points (-, *, •, ►, ▪)
  416. then: 0 if stripped.match?(/^[-*•►▪]\s+/)
  417. then: 0 else: 0 if current_list_type != :ul
  418. then: 0 else: 0 result << close_list(current_list_type, list_items) if current_list_type
  419. current_list_type = :ul
  420. list_items = []
  421. end
  422. list_items << stripped.sub(/^[-*•►▪]\s+/, "")
  423. else: 0 # Detect numbered lists (1., 2., a., b., etc.)
  424. then: 0 elsif stripped.match?(/^(\d+|[a-zA-Z])[.)]\s+/)
  425. then: 0 else: 0 if current_list_type != :ol
  426. then: 0 else: 0 result << close_list(current_list_type, list_items) if current_list_type
  427. current_list_type = :ol
  428. list_items = []
  429. end
  430. list_items << stripped.sub(/^(\d+|[a-zA-Z])[.)]\s+/, "")
  431. else: 0 # Detect implicit list items (if enabled)
  432. then: 0 elsif (detect_implicit || force_list) && stripped.present? && looks_like_implicit_list_item?(stripped)
  433. then: 0 else: 0 if current_list_type != :ul_implicit
  434. then: 0 else: 0 result << close_list(current_list_type, list_items) if current_list_type
  435. current_list_type = :ul_implicit
  436. list_items = []
  437. end
  438. list_items << stripped
  439. else
  440. else: 0 # Close any open list
  441. then: 0 else: 0 if current_list_type
  442. result << close_list(current_list_type, list_items)
  443. current_list_type = nil
  444. list_items = []
  445. end
  446. result << line
  447. end
  448. end
  449. # Close final list if any
  450. then: 0 else: 0 result << close_list(current_list_type, list_items) if current_list_type
  451. result.join("\n")
  452. end
  453. # Checks if a line looks like an implicit list item
  454. #
  455. # @param [String] line The line to check
  456. # @return [Boolean] True if it looks like a list item
  457. 1 def looks_like_implicit_list_item?(line)
  458. then: 0 else: 0 return false if line.blank?
  459. then: 0 else: 0 return false if line.length > 300 # Too long to be a list item
  460. IMPLICIT_LIST_PATTERNS.any? { |pattern| line.match?(pattern) }
  461. end
  462. # Closes a list and returns HTML
  463. #
  464. # Outputs plain HTML elements - styling is handled by the parent container's
  465. # CSS class (e.g., job-prose, doc-content).
  466. #
  467. # @param [Symbol] list_type :ul, :ol, or :ul_implicit
  468. # @param [Array<String>] items List items
  469. # @return [String] HTML list
  470. 1 def close_list(list_type, items)
  471. then: 0 else: 0 return "" if items.empty?
  472. then: 0 else: 0 tag = list_type == :ol ? "ol" : "ul"
  473. items_html = items.map do |item|
  474. # Keep inline HTML (e.g. <strong>) - sanitize happens in format_job_text
  475. "<li>#{item}</li>"
  476. end.join("\n")
  477. "<#{tag}>#{items_html}</#{tag}>"
  478. end
  479. # Converts text paragraphs to HTML <p> tags
  480. #
  481. # Outputs plain <p> elements - styling is handled by the parent container's CSS.
  482. #
  483. # @param [String] text The text
  484. # @return [String] Text with HTML paragraphs
  485. 1 def convert_paragraphs_to_html(text)
  486. # Split by double newlines (or single newlines followed by blank lines)
  487. paragraphs = text.split(/\n\n+/)
  488. paragraphs.map do |para|
  489. para = para.strip
  490. then: 0 else: 0 next "" if para.blank?
  491. # Don't wrap if it's already a block element
  492. then: 0 if para.start_with?("<h", "<ul", "<ol", "<div", "<p", "<dl", "<blockquote", "<hr")
  493. para
  494. else
  495. else: 0 # Replace single newlines with <br> within paragraphs
  496. content = para.gsub(/\n/, "<br>")
  497. "<p>#{content}</p>"
  498. end
  499. end.join("\n")
  500. end
  501. # Converts URLs in text to clickable links
  502. #
  503. # @param [String] text The text
  504. # @return [String] Text with linked URLs
  505. 1 def linkify_urls(text)
  506. url_pattern = %r{(https?://[^\s<>"]+)}
  507. text.gsub(url_pattern) do |url|
  508. "<a href=\"#{ERB::Util.html_escape(url)}\" target=\"_blank\" rel=\"noopener\">#{ERB::Util.html_escape(url)}</a>"
  509. end
  510. end
  511. # Returns allowed HTML tags for sanitization
  512. #
  513. # @return [Array<String>] Allowed tags
  514. 1 def allowed_tags
  515. %w[h2 h3 h4 h5 p br ul ol li strong em b i code a span div dl dt dd blockquote hr]
  516. end
  517. # Returns allowed HTML attributes for sanitization
  518. #
  519. # @return [Array<String>] Allowed attributes
  520. 1 def allowed_attributes
  521. %w[class href target rel]
  522. end
  523. end

app/helpers/turnstile_helper.rb

60.0% lines covered

100.0% branches covered

5 relevant lines. 3 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Helper for Cloudflare Turnstile integration
  3. 1 module TurnstileHelper
  4. # Returns the Turnstile site key
  5. #
  6. # @return [String, nil]
  7. 1 def turnstile_site_key
  8. CloudflareTurnstileService.site_key
  9. end
  10. # Checks if Turnstile is configured and enabled
  11. # Only returns true if BOTH site key and secret key are present
  12. # and the setting is enabled. This ensures the widget is only shown
  13. # when verification can actually succeed.
  14. #
  15. # @return [Boolean]
  16. 1 def turnstile_configured?
  17. Setting.turnstile_enabled? && CloudflareTurnstileService.fully_configured?
  18. end
  19. end

app/jobs/analyze_resume_job.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job for analyzing uploaded resumes with AI skill extraction
  3. #
  4. # Runs the complete analysis pipeline: text extraction -> AI analysis -> skill creation
  5. #
  6. # @example
  7. # AnalyzeResumeJob.perform_later(user_resume)
  8. #
  9. class AnalyzeResumeJob < ApplicationJob
  10. queue_as :default
  11. # Retry on transient failures (API timeouts, rate limits, etc.)
  12. retry_on StandardError, wait: :polynomially_longer, attempts: 3
  13. # Don't retry on permanent failures
  14. discard_on ActiveRecord::RecordNotFound
  15. # Analyze the resume and extract skills
  16. #
  17. # @param user_resume [UserResume] The resume to analyze
  18. # @return [void]
  19. def perform(user_resume)
  20. @user_resume = user_resume
  21. # Skip if already analyzed
  22. if user_resume.analyzed?
  23. Rails.logger.info("Resume #{user_resume.id} already analyzed, skipping")
  24. return
  25. end
  26. # Skip if currently processing (prevent duplicate jobs)
  27. if user_resume.analyzing?
  28. Rails.logger.info("Resume #{user_resume.id} already processing, skipping")
  29. return
  30. end
  31. Rails.logger.info("Starting analysis for resume #{user_resume.id}: #{user_resume.name}")
  32. result = Resumes::AnalysisService.new(user_resume).run
  33. if result[:success]
  34. Rails.logger.info(
  35. "Successfully analyzed resume #{user_resume.id}: " \
  36. "#{result[:skills_count]} skills extracted using #{result[:provider]}"
  37. )
  38. # Recompute fit scores since the user's skill profile may have changed.
  39. RecomputeFitAssessmentsForUserJob.perform_later(user_resume.user_id)
  40. else
  41. Rails.logger.error("Failed to analyze resume #{user_resume.id}: #{result[:error]}")
  42. notify_error(
  43. RuntimeError.new(result[:error]),
  44. context: "resume_analysis",
  45. severity: "warning",
  46. user: user_resume.user,
  47. user_resume_id: user_resume.id
  48. )
  49. end
  50. rescue StandardError => e
  51. handle_error(e,
  52. context: "resume_analysis",
  53. user: @user_resume&.user,
  54. user_resume_id: @user_resume&.id
  55. )
  56. end
  57. end

app/jobs/application_job.rb

0.0% lines covered

100.0% branches covered

32 relevant lines. 0 lines covered and 32 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ApplicationJob < ActiveJob::Base
  2. # Automatically retry jobs that encountered a deadlock
  3. # retry_on ActiveRecord::Deadlocked
  4. # Most jobs are safe to ignore if the underlying records are no longer available
  5. # discard_on ActiveJob::DeserializationError
  6. protected
  7. # Notifies of a general error with context
  8. #
  9. # @param exception [Exception] The exception to report
  10. # @param context [String] Error context (e.g., 'payment', 'sync')
  11. # @param severity [String] Severity level ('error', 'warning', 'info')
  12. # @param user [User, nil] User associated with the error
  13. # @param extra [Hash] Additional context
  14. # @return [void]
  15. def notify_error(exception, context:, severity: "error", user: nil, **extra)
  16. user_info = case user
  17. when User
  18. { id: user.id, email: user.email_address }
  19. when Hash
  20. user
  21. end
  22. ExceptionNotifier.notify(exception, {
  23. context: context,
  24. severity: severity,
  25. user: user_info
  26. }.merge(extra).compact)
  27. end
  28. # Notifies of an AI-related error with AI-specific context
  29. #
  30. # @param exception [Exception] The exception to report
  31. # @param operation [String, Symbol] AI operation type
  32. # @param provider [String, nil] LLM provider name
  33. # @param model [String, nil] Model identifier
  34. # @param loggable [ApplicationRecord, nil] The record being processed
  35. # @param severity [String] Severity level
  36. # @param extra [Hash] Additional context
  37. # @return [void]
  38. def notify_ai_error(exception, operation:, provider: nil, model: nil, loggable: nil, severity: "error", **extra)
  39. ai_context = {
  40. operation: operation.to_s,
  41. provider_name: provider,
  42. model_identifier: model,
  43. analyzable_type: loggable&.class&.name,
  44. analyzable_id: loggable&.id,
  45. severity: severity
  46. }.merge(extra.to_h).compact
  47. ExceptionNotifier.notify_ai_error(exception, ai_context)
  48. end
  49. # Logs an error and notifies, then optionally re-raises
  50. #
  51. # @param exception [Exception] The exception
  52. # @param context [String] Error context
  53. # @param user [User, nil] Associated user
  54. # @param reraise [Boolean] Whether to re-raise the exception
  55. # @param extra [Hash] Additional context
  56. # @return [void]
  57. def handle_error(exception, context:, user: nil, reraise: true, **extra)
  58. Rails.logger.error("[#{self.class.name}] #{exception.message}")
  59. notify_error(exception, context: context, user: user, **extra)
  60. raise exception if reraise
  61. end
  62. end

app/jobs/assistant_chat_job.rb

0.0% lines covered

100.0% branches covered

69 relevant lines. 0 lines covered and 69 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Processes an assistant chat message asynchronously.
  3. # Called after user message is created, runs LLM and broadcasts result.
  4. class AssistantChatJob < ApplicationJob
  5. queue_as :default
  6. # @param thread_id [Integer] the chat thread ID
  7. # @param user_id [Integer] the user ID
  8. # @param user_message_id [Integer] the user message ID
  9. # @param trace_id [String] the trace ID for this turn
  10. # @param client_request_uuid [String, nil] optional client request UUID for idempotency
  11. def perform(thread_id:, user_id:, user_message_id:, trace_id:, client_request_uuid: nil)
  12. thread = Assistant::ChatThread.find_by(id: thread_id)
  13. user = User.find_by(id: user_id)
  14. user_message = Assistant::ChatMessage.find_by(id: user_message_id)
  15. return unless thread && user && user_message
  16. begin
  17. result = Assistant::Chat::TurnRunner.new(
  18. user: user,
  19. thread: thread,
  20. user_message: user_message,
  21. trace_id: trace_id,
  22. client_request_uuid: client_request_uuid,
  23. page_context: user_message.metadata["page_context"] || {}
  24. ).call
  25. broadcast_assistant_message(thread, result[:assistant_message], trace_id)
  26. # Always broadcast tool action items, even if this turn deduped all tool calls and didn't create
  27. # new tool_executions. This keeps the UI consistent without requiring refresh.
  28. broadcast_tool_executions(thread)
  29. rescue StandardError => e
  30. Rails.logger.error("[AssistantChatJob] Error processing message: #{e.message}")
  31. Ai::ErrorReporter.notify(
  32. e,
  33. operation: :assistant_chat_job,
  34. provider: nil,
  35. model: nil,
  36. user: user,
  37. thread: thread,
  38. trace_id: trace_id,
  39. extra: { user_message_id: user_message.id }
  40. )
  41. broadcast_error_message(thread, trace_id, e.message)
  42. end
  43. end
  44. private
  45. def broadcast_assistant_message(thread, assistant_message, trace_id)
  46. # Remove thinking indicator and append assistant message
  47. Turbo::StreamsChannel.broadcast_remove_to(
  48. "assistant_thread_#{thread.id}",
  49. target: "thinking_#{trace_id}"
  50. )
  51. Turbo::StreamsChannel.broadcast_append_to(
  52. "assistant_thread_#{thread.id}",
  53. target: ActionView::RecordIdentifier.dom_id(thread, :messages),
  54. partial: "assistant/threads/message",
  55. locals: { message: assistant_message }
  56. )
  57. end
  58. def broadcast_tool_executions(thread)
  59. tool_executions = thread.tool_executions.order(created_at: :desc)
  60. tool_action_items = tool_executions.select { |te| te.status.in?(%w[proposed queued running]) }
  61. Turbo::StreamsChannel.broadcast_replace_to(
  62. "assistant_thread_#{thread.id}",
  63. target: ActionView::RecordIdentifier.dom_id(thread, :tool_executions),
  64. partial: "assistant/threads/tool_proposals",
  65. locals: { thread: thread, tool_executions: tool_action_items }
  66. )
  67. end
  68. def broadcast_error_message(thread, trace_id, error_message)
  69. # Remove thinking indicator
  70. Turbo::StreamsChannel.broadcast_remove_to(
  71. "assistant_thread_#{thread.id}",
  72. target: "thinking_#{trace_id}"
  73. )
  74. # Append error message
  75. Turbo::StreamsChannel.broadcast_append_to(
  76. "assistant_thread_#{thread.id}",
  77. target: ActionView::RecordIdentifier.dom_id(thread, :messages),
  78. partial: "assistant/threads/error_message",
  79. locals: { error_message: "Sorry, I encountered an issue processing your request. Please try again." }
  80. )
  81. end
  82. end

app/jobs/assistant_memory_proposer_job.rb

0.0% lines covered

100.0% branches covered

9 relevant lines. 0 lines covered and 9 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AssistantMemoryProposerJob < ApplicationJob
  3. queue_as :default
  4. def perform(user_id, thread_id, trace_id)
  5. user = User.find_by(id: user_id)
  6. thread = Assistant::ChatThread.find_by(id: thread_id)
  7. return unless user && thread
  8. Assistant::Memory::MemoryProposer.new(user: user, thread: thread, trace_id: trace_id).propose!
  9. end
  10. end

app/jobs/assistant_thread_summarizer_job.rb

0.0% lines covered

100.0% branches covered

8 relevant lines. 0 lines covered and 8 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AssistantThreadSummarizerJob < ApplicationJob
  3. queue_as :default
  4. def perform(thread_id)
  5. thread = Assistant::ChatThread.find_by(id: thread_id)
  6. return unless thread
  7. Assistant::Memory::ThreadSummarizer.new(thread: thread).maybe_summarize!
  8. end
  9. end

app/jobs/assistant_tool_execution_job.rb

0.0% lines covered

100.0% branches covered

63 relevant lines. 0 lines covered and 63 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AssistantToolExecutionJob < ApplicationJob
  3. queue_as :default
  4. def perform(tool_execution_id, approved_by_id: nil)
  5. tool_execution = Assistant::ToolExecution.find_by(id: tool_execution_id)
  6. return unless tool_execution
  7. thread = tool_execution.thread
  8. user = thread.user
  9. approved_by = approved_by_id.present? ? User.find_by(id: approved_by_id) : tool_execution.approved_by
  10. tool_execution.with_lock do
  11. return if tool_execution.status == "success"
  12. return if tool_execution.status == "running"
  13. if tool_execution.status == "proposed"
  14. tool_execution.update!(status: "queued")
  15. end
  16. end
  17. Assistant::Tools::Runner.new(user: user, tool_execution: tool_execution, approved_by: approved_by).call
  18. # Persist canonical tool result message for reliable provider follow-ups (new turns going forward).
  19. Assistant::Chat::ToolResultMessagePersister.new(tool_execution: tool_execution).call
  20. broadcast_tool_executions(thread)
  21. enqueue_followup_if_ready(tool_execution)
  22. rescue StandardError => e
  23. Ai::ErrorReporter.notify(
  24. e,
  25. operation: :assistant_tool_execution_job,
  26. provider: tool_execution&.provider_name,
  27. model: nil,
  28. user: user,
  29. thread: thread,
  30. trace_id: tool_execution&.trace_id,
  31. extra: { tool_execution_id: tool_execution_id }
  32. )
  33. raise
  34. end
  35. private
  36. def broadcast_tool_executions(thread)
  37. tool_executions = thread.tool_executions.order(created_at: :desc)
  38. tool_action_items = tool_executions.select { |te| te.status.in?(%w[proposed queued running]) }
  39. Turbo::StreamsChannel.broadcast_replace_to(
  40. "assistant_thread_#{thread.id}",
  41. target: ActionView::RecordIdentifier.dom_id(thread, :tool_executions),
  42. partial: "assistant/threads/tool_proposals",
  43. locals: { thread: thread, tool_executions: tool_action_items }
  44. )
  45. rescue StandardError => e
  46. Ai::ErrorReporter.notify(
  47. e,
  48. operation: :assistant_tool_execution_broadcast,
  49. provider: nil,
  50. model: nil,
  51. user: thread.user,
  52. thread: thread,
  53. trace_id: nil
  54. )
  55. end
  56. def enqueue_followup_if_ready(tool_execution)
  57. thread = tool_execution.thread
  58. assistant_message_id = tool_execution.assistant_message_id
  59. return unless tool_execution.status.in?(%w[success error])
  60. pending = thread.tool_executions.where(assistant_message_id: assistant_message_id, status: %w[proposed queued running]).exists?
  61. return if pending
  62. AssistantToolFollowupJob.perform_later(assistant_message_id)
  63. rescue StandardError
  64. # best-effort only
  65. end
  66. end

app/jobs/assistant_tool_followup_job.rb

0.0% lines covered

100.0% branches covered

50 relevant lines. 0 lines covered and 50 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. class AssistantToolFollowupJob < ApplicationJob
  3. queue_as :default
  4. # @param assistant_message_id [Integer] The assistant message that originated the tool calls.
  5. def perform(assistant_message_id)
  6. assistant_message = Assistant::ChatMessage.find_by(id: assistant_message_id)
  7. return unless assistant_message
  8. thread = assistant_message.thread
  9. user = thread.user
  10. thread.with_lock do
  11. # Only run follow-up for placeholder messages that are waiting on tool results.
  12. pending_followup = assistant_message.metadata["pending_tool_followup"] == true || assistant_message.metadata[:pending_tool_followup] == true
  13. return unless pending_followup
  14. pending = thread.tool_executions.where(assistant_message_id: assistant_message.id, status: %w[proposed queued running]).exists?
  15. return if pending
  16. end
  17. result = Assistant::Chat::Components::ToolFollowupResponder.new(
  18. user: user,
  19. thread: thread,
  20. originating_assistant_message: assistant_message
  21. ).call
  22. assistant_message.update!(
  23. content: result[:answer].to_s,
  24. metadata: assistant_message.metadata.merge(
  25. pending_tool_followup: false,
  26. tool_followup_completed_at: Time.current.iso8601
  27. )
  28. )
  29. broadcast_assistant_message_replace(thread, assistant_message)
  30. rescue StandardError => e
  31. Ai::ErrorReporter.notify(
  32. e,
  33. operation: :assistant_tool_followup_job,
  34. provider: (assistant_message&.metadata&.dig("provider") || assistant_message&.metadata&.dig(:provider)),
  35. model: (assistant_message&.metadata&.dig("model") || assistant_message&.metadata&.dig(:model)),
  36. user: user,
  37. thread: thread,
  38. trace_id: (assistant_message&.metadata&.dig("trace_id") || assistant_message&.metadata&.dig(:trace_id)),
  39. extra: { assistant_message_id: assistant_message_id }
  40. )
  41. raise
  42. end
  43. private
  44. def broadcast_assistant_message_replace(thread, assistant_message)
  45. Turbo::StreamsChannel.broadcast_replace_to(
  46. "assistant_thread_#{thread.id}",
  47. target: ActionView::RecordIdentifier.dom_id(assistant_message),
  48. partial: "assistant/threads/message",
  49. locals: { message: assistant_message }
  50. )
  51. rescue StandardError
  52. # best-effort only
  53. end
  54. end

app/jobs/billing/process_webhook_event_job.rb

0.0% lines covered

100.0% branches covered

19 relevant lines. 0 lines covered and 19 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Processes a stored billing webhook event asynchronously.
  4. class ProcessWebhookEventJob < ApplicationJob
  5. queue_as :default
  6. retry_on StandardError, wait: :polynomially_longer, attempts: 3
  7. # @param webhook_event [Billing::WebhookEvent]
  8. def perform(webhook_event)
  9. webhook_event = Billing::WebhookEvent.find(webhook_event.id) unless webhook_event.is_a?(Billing::WebhookEvent)
  10. return unless webhook_event.status == "pending"
  11. processor = Billing::Webhooks::Processor.new(webhook_event)
  12. processor.run
  13. rescue StandardError => e
  14. handle_error(e,
  15. context: "billing_webhook_processing",
  16. webhook_event_id: webhook_event&.id,
  17. provider: webhook_event&.provider,
  18. event_type: webhook_event&.event_type
  19. )
  20. end
  21. end
  22. end

app/jobs/cleanup_stuck_scraping_attempts_job.rb

0.0% lines covered

100.0% branches covered

66 relevant lines. 0 lines covered and 66 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job to detect and clean up stuck scraping attempts
  3. #
  4. # Scraping attempts can get stuck in intermediate states (fetching, extracting)
  5. # if the process hangs or crashes without proper error handling.
  6. #
  7. # This job runs periodically to:
  8. # 1. Find attempts stuck in intermediate states for too long
  9. # 2. Mark them as failed with appropriate error messages
  10. # 3. Optionally retry or notify for manual review
  11. #
  12. # @example Run manually
  13. # CleanupStuckScrapingAttemptsJob.perform_now
  14. #
  15. # @example Schedule (add to config/recurring.yml for solid_queue)
  16. # cleanup_stuck_scraping_attempts:
  17. # class: CleanupStuckScrapingAttemptsJob
  18. # schedule: every 10 minutes
  19. class CleanupStuckScrapingAttemptsJob < ApplicationJob
  20. queue_as :maintenance
  21. # How long an attempt can be in an intermediate state before considered stuck
  22. STUCK_THRESHOLD_MINUTES = 10
  23. # Intermediate states that should transition quickly
  24. INTERMEDIATE_STATES = %w[pending fetching extracting retrying].freeze
  25. def perform
  26. stuck_attempts = find_stuck_attempts
  27. return if stuck_attempts.empty?
  28. Rails.logger.info({
  29. event: "cleanup_stuck_attempts_started",
  30. count: stuck_attempts.count
  31. }.to_json)
  32. cleaned_count = 0
  33. stuck_attempts.find_each do |attempt|
  34. cleanup_attempt(attempt)
  35. cleaned_count += 1
  36. end
  37. Rails.logger.info({
  38. event: "cleanup_stuck_attempts_completed",
  39. cleaned_count: cleaned_count
  40. }.to_json)
  41. end
  42. private
  43. def find_stuck_attempts
  44. threshold = STUCK_THRESHOLD_MINUTES.minutes.ago
  45. ScrapingAttempt
  46. .where(status: INTERMEDIATE_STATES)
  47. .where("updated_at < ?", threshold)
  48. .order(updated_at: :asc)
  49. end
  50. def cleanup_attempt(attempt)
  51. # Determine which step was stuck
  52. last_event = attempt.scraping_events.order(created_at: :desc).first
  53. stuck_step = last_event&.event_type || attempt.status
  54. # Check if there's an incomplete event (started but not completed)
  55. incomplete_event = attempt.scraping_events.find_by(status: :started)
  56. if incomplete_event
  57. incomplete_event.update!(
  58. status: :failed,
  59. completed_at: Time.current,
  60. error_type: "StuckTimeout",
  61. error_message: "Step timed out after #{STUCK_THRESHOLD_MINUTES} minutes"
  62. )
  63. end
  64. # Mark the attempt as failed
  65. attempt.update!(
  66. status: :failed,
  67. failed_step: stuck_step,
  68. error_message: "Attempt stuck at '#{stuck_step}' for over #{STUCK_THRESHOLD_MINUTES} minutes - automatically cleaned up"
  69. )
  70. Rails.logger.warn({
  71. event: "stuck_attempt_cleaned",
  72. scraping_attempt_id: attempt.id,
  73. job_listing_id: attempt.job_listing_id,
  74. stuck_step: stuck_step,
  75. stuck_since: attempt.updated_at.iso8601
  76. }.to_json)
  77. # Notify for monitoring
  78. ExceptionNotifier.notify(
  79. StandardError.new("Stuck scraping attempt cleaned up"),
  80. {
  81. context: "stuck_attempt_cleanup",
  82. severity: "warning",
  83. scraping_attempt_id: attempt.id,
  84. job_listing_id: attempt.job_listing_id,
  85. stuck_step: stuck_step,
  86. stuck_duration_minutes: ((Time.current - attempt.updated_at) / 60).round
  87. }
  88. )
  89. end
  90. end

app/jobs/compute_fit_assessment_job.rb

0.0% lines covered

100.0% branches covered

9 relevant lines. 0 lines covered and 9 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job to compute a FitAssessment for a given (user, fittable).
  3. class ComputeFitAssessmentJob < ApplicationJob
  4. queue_as :default
  5. discard_on ActiveRecord::RecordNotFound
  6. # @param user_id [Integer]
  7. # @param fittable_type [String]
  8. # @param fittable_id [Integer]
  9. def perform(user_id, fittable_type, fittable_id)
  10. user = User.find(user_id)
  11. fittable = fittable_type.constantize.find(fittable_id)
  12. ComputeFitAssessmentService.new(user: user, fittable: fittable).call
  13. end
  14. end

app/jobs/generate_interview_prep_pack_job.rb

0.0% lines covered

100.0% branches covered

31 relevant lines. 0 lines covered and 31 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Generates and caches interview prep artifacts for an application.
  3. #
  4. # Enforces quota usage once per pack refresh (monthly window via Billing::UsageCounter).
  5. class GenerateInterviewPrepPackJob < ApplicationJob
  6. queue_as :default
  7. # @param interview_application [InterviewApplication]
  8. # @param user [User]
  9. def perform(interview_application, user:)
  10. ent = Billing::Entitlements.for(user)
  11. return unless ent.allowed?(:interview_prepare_access)
  12. remaining = ent.remaining(:interview_prepare_refreshes)
  13. return if remaining.is_a?(Integer) && remaining <= 0
  14. period = {
  15. starts_at: Time.current.beginning_of_month,
  16. ends_at: (Time.current.beginning_of_month + 1.month)
  17. }
  18. Billing::UsageCounter.increment!(
  19. user: user,
  20. feature_key: "interview_prepare_refreshes",
  21. period_starts_at: period[:starts_at],
  22. period_ends_at: period[:ends_at],
  23. delta: 1
  24. )
  25. InterviewPrep::GenerateMatchAnalysisService.new(user: user, interview_application: interview_application).call
  26. InterviewPrep::GenerateFocusAreasService.new(user: user, interview_application: interview_application).call
  27. InterviewPrep::GenerateQuestionFramingService.new(user: user, interview_application: interview_application).call
  28. InterviewPrep::GenerateStrengthPositioningService.new(user: user, interview_application: interview_application).call
  29. rescue StandardError => e
  30. handle_error(e,
  31. context: "interview_prep_generation",
  32. user: user,
  33. interview_application_id: interview_application&.id,
  34. reraise: false # Don't retry - each service handles its own failures
  35. )
  36. end
  37. end

app/jobs/generate_round_prep_job.rb

0.0% lines covered

100.0% branches covered

36 relevant lines. 0 lines covered and 36 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Generates round-specific interview prep for an interview round.
  3. #
  4. # Uses InterviewRoundPrep::GenerateService to generate tailored preparation
  5. # content based on round type, historical performance, and company patterns.
  6. #
  7. # Enforces quota usage per generation (monthly window via Billing::UsageCounter).
  8. class GenerateRoundPrepJob < ApplicationJob
  9. queue_as :default
  10. # @param interview_round [InterviewRound]
  11. # @param force [Boolean] Force regeneration even if prep exists
  12. def perform(interview_round, force: false)
  13. user = interview_round.interview_application.user
  14. # Check entitlements
  15. ent = Billing::Entitlements.for(user)
  16. return unless ent.allowed?(:round_prep_access)
  17. # Check quota
  18. remaining = ent.remaining(:round_prep_generations)
  19. return if remaining.is_a?(Integer) && remaining <= 0
  20. # Track usage
  21. period = {
  22. starts_at: Time.current.beginning_of_month,
  23. ends_at: (Time.current.beginning_of_month + 1.month)
  24. }
  25. Billing::UsageCounter.increment!(
  26. user: user,
  27. feature_key: "round_prep_generations",
  28. period_starts_at: period[:starts_at],
  29. period_ends_at: period[:ends_at],
  30. delta: 1
  31. )
  32. # Generate the prep
  33. InterviewRoundPrep::GenerateService.new(
  34. interview_round: interview_round,
  35. force: force
  36. ).call
  37. rescue StandardError => e
  38. # Mark artifact as failed
  39. artifact = InterviewRoundPrepArtifact.find_by(
  40. interview_round: interview_round,
  41. kind: :comprehensive
  42. )
  43. artifact&.fail!(e.message)
  44. handle_error(e,
  45. context: "round_prep_generation",
  46. user: user,
  47. interview_round_id: interview_round.id
  48. )
  49. end
  50. end

app/jobs/gmail_sync_all_users_job.rb

0.0% lines covered

100.0% branches covered

13 relevant lines. 0 lines covered and 13 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job for syncing Gmail emails for all users with connected accounts
  3. # This job is scheduled to run periodically via Solid Queue recurring jobs
  4. class GmailSyncAllUsersJob < ApplicationJob
  5. queue_as :default
  6. # Performs Gmail sync for all users with connected Google accounts
  7. # Only syncs accounts that have sync enabled and don't need reauthorization
  8. # Accounts with expired access tokens will still be processed (refresh will be attempted)
  9. def perform
  10. accounts_to_sync = ConnectedAccount.google.sync_enabled.ready_for_sync
  11. Rails.logger.info "Starting Gmail sync for #{accounts_to_sync.count} accounts"
  12. accounts_to_sync.find_each do |account|
  13. # Queue individual sync job for each account
  14. # This allows individual syncs to fail without affecting others
  15. GmailSyncJob.perform_later(account.user, connected_account: account)
  16. rescue StandardError => e
  17. Rails.logger.error "Failed to queue sync for account #{account.id}: #{e.message}"
  18. end
  19. Rails.logger.info "Gmail sync jobs queued for #{accounts_to_sync.count} accounts"
  20. end
  21. end

app/jobs/gmail_sync_job.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job for syncing Gmail emails
  3. # This job fetches interview-related emails from the user's Gmail account
  4. class GmailSyncJob < ApplicationJob
  5. queue_as :default
  6. # Number of times to retry on transient failures
  7. retry_on Gmail::Errors::TokenExpiredError, wait: :polynomially_longer, attempts: 3
  8. retry_on Google::Apis::TransmissionError, wait: :polynomially_longer, attempts: 3
  9. # Don't retry on auth errors - user needs to reconnect
  10. # Use a block to mark account as needing reauth when discarded
  11. discard_on Google::Apis::AuthorizationError do |job, error|
  12. handle_auth_failure(job, error)
  13. end
  14. # Performs the Gmail sync for a user
  15. #
  16. # @param user [User] The user to sync emails for
  17. # @param connected_account [ConnectedAccount, nil] Specific account to sync (optional)
  18. def perform(user, connected_account: nil)
  19. account = connected_account || user.google_account
  20. unless account
  21. Rails.logger.info "No Google account connected for user #{user.id}"
  22. return
  23. end
  24. unless account.sync_enabled?
  25. Rails.logger.info "Gmail sync disabled for user #{user.id}"
  26. return
  27. end
  28. # Store account for potential error handling
  29. @account = account
  30. service = Gmail::SyncService.new(account)
  31. result = service.run
  32. if result[:success]
  33. Rails.logger.info "Gmail sync completed for user #{user.id}: #{result[:emails_found]} emails found"
  34. else
  35. Rails.logger.warn "Gmail sync failed for user #{user.id}: #{result[:error]}"
  36. # If reauth is needed, mark account and notify user
  37. if result[:needs_reauth]
  38. mark_needs_reauth_and_notify(account, result[:error])
  39. end
  40. end
  41. result
  42. end
  43. private
  44. # Marks the account as needing reauthorization and sends notification
  45. #
  46. # @param account [ConnectedAccount] The connected account
  47. # @param error_message [String] The error message
  48. def mark_needs_reauth_and_notify(account, error_message)
  49. account.mark_needs_reauth!(error_message)
  50. ConnectedAccountMailer.reauth_required(account).deliver_later
  51. Rails.logger.info "Marked account #{account.id} as needing reauth and sent notification"
  52. end
  53. # Class method to handle auth failures from discard_on callback
  54. #
  55. # @param job [GmailSyncJob] The job instance
  56. # @param error [Exception] The error that caused the discard
  57. def self.handle_auth_failure(job, error)
  58. # Extract the account from job arguments
  59. user = job.arguments.first
  60. account = job.arguments.second&.fetch(:connected_account, nil) || user&.google_account
  61. return unless account
  62. Rails.logger.error "Gmail authorization failed for account #{account.id}: #{error.message}"
  63. account.mark_needs_reauth!(error.message)
  64. ConnectedAccountMailer.reauth_required(account).deliver_later
  65. end
  66. end

app/jobs/process_opportunity_email_job.rb

0.0% lines covered

100.0% branches covered

21 relevant lines. 0 lines covered and 21 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job for processing opportunity emails with AI extraction
  3. #
  4. # Runs AI extraction on opportunities to extract job details from recruiter emails.
  5. # Called after a recruiter outreach email is detected and an opportunity is created.
  6. #
  7. # @example
  8. # ProcessOpportunityEmailJob.perform_later(opportunity.id)
  9. #
  10. class ProcessOpportunityEmailJob < ApplicationJob
  11. queue_as :default
  12. # Retry on transient failures
  13. retry_on StandardError, wait: :polynomially_longer, attempts: 3
  14. # Don't retry on permanent failures
  15. discard_on ActiveRecord::RecordNotFound
  16. # Process the opportunity with AI extraction
  17. #
  18. # @param opportunity_id [Integer] The opportunity ID to process
  19. # @return [void]
  20. def perform(opportunity_id)
  21. opportunity = Opportunity.find(opportunity_id)
  22. # Skip if already processed (has extracted data)
  23. return if opportunity.extracted_data["extracted_at"].present?
  24. # Skip if no email attached
  25. return unless opportunity.synced_email.present?
  26. Rails.logger.info("Processing opportunity #{opportunity_id} for AI extraction")
  27. # Run AI extraction
  28. service = Opportunities::ExtractionService.new(opportunity)
  29. result = service.extract
  30. if result[:success]
  31. Rails.logger.info("Successfully extracted data for opportunity #{opportunity_id}")
  32. # If we found a job URL, we could optionally trigger job listing scraping
  33. # For now, we just store the extracted data
  34. if opportunity.reload.job_url.present?
  35. Rails.logger.info("Opportunity #{opportunity_id} has job URL: #{opportunity.job_url}")
  36. end
  37. else
  38. Rails.logger.warn("Failed to extract data for opportunity #{opportunity_id}: #{result[:error]}")
  39. end
  40. end
  41. end

app/jobs/process_signal_extraction_job.rb

0.0% lines covered

100.0% branches covered

126 relevant lines. 0 lines covered and 126 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job for extracting actionable signals from synced emails
  3. #
  4. # Runs AI extraction on synced emails to extract company info, recruiter details,
  5. # job information, and suggested actions. Also triggers automated email processors
  6. # to create interview rounds, update statuses, and capture feedback.
  7. #
  8. # @example
  9. # ProcessSignalExtractionJob.perform_later(synced_email.id)
  10. #
  11. class ProcessSignalExtractionJob < ApplicationJob
  12. queue_as :default
  13. # Retry on transient failures
  14. retry_on StandardError, wait: :polynomially_longer, attempts: 3
  15. # Don't retry on permanent failures
  16. discard_on ActiveRecord::RecordNotFound
  17. # Process the email with AI signal extraction
  18. #
  19. # @param synced_email_id [Integer] The synced email ID to process
  20. # @param run_id [Integer, nil] Optional Signals::EmailPipelineRun id
  21. # @return [void]
  22. def perform(synced_email_id, run_id = nil)
  23. @synced_email = SyncedEmail.find(synced_email_id)
  24. @run_id = run_id
  25. new_pipeline_enabled =
  26. Setting.signals_decision_shadow_enabled? ||
  27. Setting.signals_decision_execution_enabled? ||
  28. Setting.signals_email_facts_extraction_enabled?
  29. run = Signals::EmailPipelineRun.find_by(id: run_id) if run_id.present?
  30. run ||= Signals::EmailPipelineRun.create!(
  31. synced_email: @synced_email,
  32. user: @synced_email.user,
  33. connected_account: @synced_email.connected_account,
  34. status: :started,
  35. trigger: run_id.present? ? "gmail_sync" : "manual",
  36. mode: "mixed",
  37. started_at: Time.current,
  38. metadata: { "source" => "process_signal_extraction_job" }
  39. )
  40. recorder = Signals::Observability::EmailPipelineRecorder.for_run(run)
  41. # Legacy signal extraction is not the same thing as the new Facts/Decision pipeline.
  42. # If the new pipeline is enabled, we still want to run it even if legacy extraction
  43. # already ran (or was skipped).
  44. if @synced_email.extraction_completed? && !new_pipeline_enabled
  45. recorder&.finish_success!(metadata: { "final" => "skipped", "reason" => "already_extracted" })
  46. return
  47. end
  48. if @synced_email.extraction_status == "skipped" && !new_pipeline_enabled
  49. recorder&.finish_success!(metadata: { "final" => "skipped", "reason" => "extraction_status_skipped" })
  50. return
  51. end
  52. Rails.logger.info("Processing signal extraction for email #{synced_email_id}")
  53. legacy_result = nil
  54. if Setting.signals_email_facts_extraction_enabled?
  55. # When EmailFacts is enabled, legacy signal extraction is optional and should not block.
  56. legacy_result = { success: false, skipped: true, reason: "signals_email_facts_extraction_enabled" }
  57. recorder&.event!(
  58. event_type: :legacy_signal_extraction,
  59. status: :skipped,
  60. input_payload: { "synced_email_id" => synced_email_id },
  61. output_payload: { "skipped" => true, "reason" => legacy_result[:reason] }
  62. )
  63. else
  64. # Run legacy AI extraction (service handles its own error notification)
  65. service = Signals::ExtractionService.new(@synced_email)
  66. legacy_result = recorder&.measure(
  67. :legacy_signal_extraction,
  68. input_payload: { "synced_email_id" => synced_email_id, "email_type" => @synced_email.email_type, "matched" => @synced_email.matched? },
  69. output_payload_override: lambda { |r|
  70. {
  71. "success" => r[:success],
  72. "skipped" => r[:skipped],
  73. "reason" => r[:reason],
  74. "error" => r[:error]
  75. }.compact
  76. }
  77. ) { service.extract } || service.extract
  78. end
  79. legacy_ok = legacy_result[:success]
  80. # Always attempt the new pipeline when enabled, even if legacy extraction failed.
  81. if Setting.signals_decision_shadow_enabled?
  82. Signals::Decisioning::ShadowRunner.new(@synced_email, pipeline_run: run).call
  83. end
  84. # Optional execution mode (guarded by Setting + semantic validation).
  85. executed = false
  86. if Setting.signals_decision_execution_enabled?
  87. executed = Signals::Decisioning::ExecutionRunner.new(@synced_email, pipeline_run: run).call
  88. end
  89. # Single-writer gate:
  90. # - If the new execution runner successfully applied a plan, do NOT also run legacy orchestration.
  91. # - Otherwise, fall back to the legacy system ONLY if legacy extraction succeeded.
  92. if executed
  93. Rails.logger.info("Signals decision execution applied; skipping legacy orchestration for email #{synced_email_id}")
  94. recorder&.finish_success!(metadata: { "final" => "executed_new" })
  95. return
  96. end
  97. if legacy_ok
  98. Rails.logger.info("Successfully extracted signals for email #{synced_email_id}")
  99. @synced_email.reload
  100. Rails.logger.info(" Company: #{@synced_email.signal_company_name}") if @synced_email.signal_company_name.present?
  101. recorder&.measure(
  102. :legacy_orchestrator,
  103. input_payload: { "synced_email_id" => synced_email_id, "email_type" => @synced_email.email_type, "matched" => @synced_email.matched? },
  104. output_payload_override: lambda { |r| { "result" => r } }
  105. ) { process_email_actions(@synced_email) }
  106. recorder&.finish_success!(metadata: { "final" => "executed_legacy" })
  107. return
  108. end
  109. if legacy_result[:skipped]
  110. Rails.logger.info("Skipped legacy signal extraction for email #{synced_email_id}: #{legacy_result[:reason]}")
  111. recorder&.finish_success!(metadata: { "final" => new_pipeline_enabled ? "new_pipeline_no_execution" : "skipped", "reason" => legacy_result[:reason] })
  112. return
  113. end
  114. # Legacy extraction failed and we didn't execute a new plan.
  115. if new_pipeline_enabled
  116. Rails.logger.warn("Legacy signal extraction failed but new pipeline enabled; continuing. email=#{synced_email_id} error=#{legacy_result[:error]}")
  117. recorder&.finish_success!(metadata: { "final" => "new_pipeline_no_execution", "legacy_error" => legacy_result[:error] }.compact)
  118. else
  119. Rails.logger.warn("Failed to extract signals for email #{synced_email_id}: #{legacy_result[:error]}")
  120. recorder&.finish_failed!(RuntimeError.new(legacy_result[:error].to_s), metadata: { "final" => "failed_legacy_extraction" })
  121. end
  122. rescue StandardError => e
  123. begin
  124. if defined?(recorder) && recorder
  125. recorder.finish_failed!(e, metadata: { "final" => "exception" })
  126. end
  127. rescue StandardError
  128. # best-effort only
  129. end
  130. # Note: Individual services (ExtractionService, processors) handle their own error notifications.
  131. # This catch is for unexpected errors outside the service calls.
  132. handle_error(e,
  133. context: "signal_extraction",
  134. user: @synced_email&.user,
  135. synced_email_id: synced_email_id
  136. )
  137. end
  138. private
  139. # Processes automated actions based on email type
  140. # Note: Individual processors handle their own error notifications.
  141. #
  142. # @param synced_email [SyncedEmail]
  143. def process_email_actions(synced_email)
  144. return unless synced_email.matched?
  145. Rails.logger.info("Processing automated actions for email #{synced_email.id} (type: #{synced_email.email_type})")
  146. orchestrator_result = Signals::EmailStateOrchestrator.new(synced_email).call
  147. # Always try to capture feedback if available
  148. feedback_result = process_company_feedback(synced_email) if synced_email.matched?
  149. { "orchestrator" => orchestrator_result, "company_feedback" => feedback_result }.compact
  150. end
  151. # Processes company feedback capture
  152. #
  153. # @param synced_email [SyncedEmail]
  154. def process_company_feedback(synced_email)
  155. processor = Signals::CompanyFeedbackProcessor.new(synced_email)
  156. result = processor.process
  157. if result[:success]
  158. Rails.logger.info("Captured company feedback from email #{synced_email.id}")
  159. elsif result[:skipped]
  160. # Don't log skipped feedback - this is common
  161. else
  162. Rails.logger.warn("Failed to capture company feedback: #{result[:error]}")
  163. end
  164. result
  165. end
  166. end

app/jobs/purge_deleted_interview_applications_job.rb

0.0% lines covered

100.0% branches covered

9 relevant lines. 0 lines covered and 9 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Purges soft-deleted interview applications that have been in Deleted for longer than the retention period.
  3. #
  4. # Intended to be run on a schedule (e.g. daily) via Solid Queue / cron / deploy scheduler.
  5. class PurgeDeletedInterviewApplicationsJob < ApplicationJob
  6. queue_as :default
  7. # @param retention_period [ActiveSupport::Duration] Time to retain deleted records before hard deletion.
  8. def perform(retention_period: 3.months)
  9. cutoff = Time.current - retention_period
  10. InterviewApplication.deleted.where("deleted_at < ?", cutoff).find_each do |application|
  11. application.destroy!
  12. end
  13. end
  14. end

app/jobs/recompute_fit_assessments_for_job_listing_job.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job to enqueue fit recomputation for items impacted by a job listing update.
  3. class RecomputeFitAssessmentsForJobListingJob < ApplicationJob
  4. queue_as :default
  5. discard_on ActiveRecord::RecordNotFound
  6. # @param job_listing_id [Integer]
  7. def perform(job_listing_id)
  8. job_listing = JobListing.find(job_listing_id)
  9. InterviewApplication.where(job_listing_id: job_listing.id).find_each do |application|
  10. ComputeFitAssessmentJob.perform_later(application.user_id, "InterviewApplication", application.id)
  11. end
  12. # Saved jobs created from a pasted URL.
  13. SavedJob.active.where(url: job_listing.url).find_each do |saved_job|
  14. ComputeFitAssessmentJob.perform_later(saved_job.user_id, "SavedJob", saved_job.id)
  15. end
  16. # Opportunities with a matching job URL.
  17. Opportunity.where(job_url: job_listing.url).find_each do |opportunity|
  18. ComputeFitAssessmentJob.perform_later(opportunity.user_id, "Opportunity", opportunity.id)
  19. end
  20. end
  21. end

app/jobs/recompute_fit_assessments_for_user_job.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job to enqueue fit recomputation for all relevant items for a user.
  3. class RecomputeFitAssessmentsForUserJob < ApplicationJob
  4. queue_as :default
  5. discard_on ActiveRecord::RecordNotFound
  6. # @param user_id [Integer]
  7. def perform(user_id)
  8. user = User.find(user_id)
  9. user.opportunities.actionable.find_each do |opportunity|
  10. ComputeFitAssessmentJob.perform_later(user.id, "Opportunity", opportunity.id)
  11. end
  12. user.saved_jobs.active.unconverted.find_each do |saved_job|
  13. ComputeFitAssessmentJob.perform_later(user.id, "SavedJob", saved_job.id)
  14. end
  15. user.interview_applications.active.find_each do |application|
  16. ComputeFitAssessmentJob.perform_later(user.id, "InterviewApplication", application.id)
  17. end
  18. end
  19. end

app/jobs/refresh_oauth_tokens_job.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job for proactively refreshing OAuth tokens before they expire
  3. # This helps prevent sync failures due to expired tokens
  4. class RefreshOauthTokensJob < ApplicationJob
  5. queue_as :default
  6. # Refreshes OAuth tokens that are about to expire
  7. # This job runs periodically to ensure tokens are fresh before sync jobs need them
  8. def perform
  9. # Find accounts with tokens expiring within the next hour
  10. # that haven't been marked as needing reauth
  11. accounts_to_refresh = ConnectedAccount.google
  12. .sync_enabled
  13. .ready_for_sync
  14. .expiring_soon
  15. Rails.logger.info "Starting proactive token refresh for #{accounts_to_refresh.count} accounts"
  16. refreshed_count = 0
  17. failed_count = 0
  18. accounts_to_refresh.find_each do |account|
  19. refresh_account_token(account)
  20. refreshed_count += 1
  21. rescue Signet::AuthorizationError => e
  22. handle_refresh_failure(account, e)
  23. failed_count += 1
  24. rescue StandardError => e
  25. Rails.logger.error "Unexpected error refreshing token for account #{account.id}: #{e.message}"
  26. failed_count += 1
  27. end
  28. Rails.logger.info "Token refresh completed: #{refreshed_count} refreshed, #{failed_count} failed"
  29. end
  30. private
  31. # Refreshes the token for a single account
  32. #
  33. # @param account [ConnectedAccount] The account to refresh
  34. def refresh_account_token(account)
  35. return unless account.refreshable?
  36. client_service = Gmail::ClientService.new(account)
  37. client_service.send(:refresh_token!)
  38. Rails.logger.debug "Successfully refreshed token for account #{account.id}"
  39. end
  40. # Handles a failed token refresh
  41. #
  42. # @param account [ConnectedAccount] The account that failed to refresh
  43. # @param error [Signet::AuthorizationError] The error
  44. def handle_refresh_failure(account, error)
  45. Rails.logger.warn "Token refresh failed for account #{account.id}: #{error.message}"
  46. # Mark the account as needing reauthorization
  47. account.mark_needs_reauth!(error.message)
  48. # Notify the user
  49. ConnectedAccountMailer.reauth_required(account).deliver_later
  50. end
  51. end

app/jobs/scrape_job_listing_job.rb

0.0% lines covered

100.0% branches covered

156 relevant lines. 0 lines covered and 156 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Background job to scrape job listing details from a URL
  3. #
  4. # Uses the Scraping::OrchestratorService to extract data via API or AI.
  5. # Implements automatic retries with exponential backoff and DLQ handling.
  6. class ScrapeJobListingJob < ApplicationJob
  7. queue_as :default
  8. # Don't retry on serialization errors
  9. discard_on ActiveJob::DeserializationError
  10. # Note: We use manual retry logic instead of retry_on to prevent
  11. # exponential job growth. Retries are handled via ScrapingAttempt
  12. # and manual perform_later scheduling with attempt tracking.
  13. # Scrape job listing details using the orchestration service
  14. #
  15. # Supports smart retries using cached HTML when available.
  16. #
  17. # @param [JobListing] job_listing The job listing to scrape
  18. # @param [Integer, nil] scraping_attempt_id Optional scraping attempt ID for retries
  19. def perform(job_listing, scraping_attempt_id: nil)
  20. return unless job_listing.url.present?
  21. # If we have a scraping attempt ID, this is a retry - use RetryService
  22. if scraping_attempt_id
  23. attempt = ScrapingAttempt.find_by(id: scraping_attempt_id)
  24. if attempt && (attempt.failed? || attempt.retrying?)
  25. retry_with_service(attempt)
  26. return
  27. end
  28. end
  29. # Use the orchestrator service for extraction
  30. orchestrator = Scraping::OrchestratorService.new(job_listing)
  31. success = orchestrator.call
  32. if success
  33. Rails.logger.info({
  34. event: "job_scraping_succeeded",
  35. job_listing_id: job_listing.id,
  36. url: job_listing.url
  37. }.to_json)
  38. # Job listing details may have changed; recompute fit scores for dependent items.
  39. RecomputeFitAssessmentsForJobListingJob.perform_later(job_listing.id)
  40. else
  41. handle_extraction_failure(job_listing)
  42. end
  43. rescue => e
  44. # Log the error with full context
  45. Rails.logger.error({
  46. event: "job_scraping_error",
  47. job_listing_id: job_listing.id,
  48. url: job_listing.url,
  49. error: e.class.name,
  50. message: e.message,
  51. backtrace: e.backtrace&.first(5)
  52. }.to_json)
  53. # Handle failure and don't re-raise to prevent ActiveJob retry
  54. handle_extraction_failure(job_listing)
  55. end
  56. private
  57. # Retries extraction using RetryService with cached HTML
  58. #
  59. # @param [ScrapingAttempt] attempt The failed attempt
  60. def retry_with_service(attempt)
  61. retry_service = Scraping::RetryService.new(attempt)
  62. # Determine which step to retry based on failed_step
  63. result = case attempt.failed_step
  64. when "html_fetch"
  65. retry_service.retry_html_fetch
  66. when "api_extraction", "ai_extraction"
  67. retry_service.retry_extraction
  68. else
  69. # Unknown step or orchestration failure - retry full
  70. retry_service.retry_full
  71. end
  72. if result[:success]
  73. Rails.logger.info({
  74. event: "job_scraping_retry_succeeded",
  75. scraping_attempt_id: attempt.id,
  76. job_listing_id: attempt.job_listing_id,
  77. failed_step: attempt.failed_step
  78. }.to_json)
  79. else
  80. handle_retry_failure(attempt)
  81. end
  82. rescue => e
  83. Rails.logger.error({
  84. event: "job_scraping_retry_error",
  85. scraping_attempt_id: attempt.id,
  86. job_listing_id: attempt.job_listing_id,
  87. error: e.class.name,
  88. message: e.message
  89. }.to_json)
  90. handle_retry_failure(attempt)
  91. # Don't re-raise to prevent ActiveJob retry
  92. end
  93. # Handles extraction failure and schedules retries
  94. #
  95. # @param [JobListing] job_listing The job listing
  96. def handle_extraction_failure(job_listing)
  97. attempt = job_listing.scraping_attempts.order(created_at: :desc).first
  98. if attempt
  99. classifier = Scraping::FailureClassifierService.new(attempt)
  100. unless classifier.retryable?
  101. Rails.logger.info({
  102. event: "job_scraping_not_retryable",
  103. job_listing_id: job_listing.id,
  104. scraping_attempt_id: attempt.id,
  105. url: job_listing.url,
  106. failed_step: attempt.failed_step,
  107. error_message: attempt.error_message,
  108. retry_count: attempt.retry_count
  109. }.to_json)
  110. return
  111. end
  112. # Use retry_count from attempt, not executions (which is from ActiveJob)
  113. current_retry_count = attempt.retry_count || 0
  114. # After 3 attempts, send to DLQ
  115. if current_retry_count >= 3
  116. attempt.send_to_dlq!
  117. notify_admin_of_dlq(attempt)
  118. Rails.logger.error({
  119. event: "job_scraping_sent_to_dlq",
  120. job_listing_id: job_listing.id,
  121. scraping_attempt_id: attempt.id,
  122. url: job_listing.url,
  123. failed_step: attempt.failed_step,
  124. retry_count: current_retry_count
  125. }.to_json)
  126. else
  127. # Increment retry count
  128. attempt.update(retry_count: current_retry_count + 1)
  129. attempt.retry_attempt!
  130. # Schedule retry with attempt ID for smart retry
  131. ScrapeJobListingJob.perform_later(job_listing, scraping_attempt_id: attempt.id)
  132. Rails.logger.warn({
  133. event: "job_scraping_retry_scheduled",
  134. job_listing_id: job_listing.id,
  135. scraping_attempt_id: attempt.id,
  136. url: job_listing.url,
  137. failed_step: attempt.failed_step,
  138. retry_count: current_retry_count + 1,
  139. max_attempts: 3
  140. }.to_json)
  141. end
  142. end
  143. # Don't re-raise - we handle retries manually to prevent ActiveJob retry
  144. end
  145. # Handles retry failure
  146. #
  147. # @param [ScrapingAttempt] attempt The failed attempt
  148. def handle_retry_failure(attempt)
  149. classifier = Scraping::FailureClassifierService.new(attempt)
  150. unless classifier.retryable?
  151. Rails.logger.info({
  152. event: "job_scraping_retry_not_retryable",
  153. scraping_attempt_id: attempt.id,
  154. job_listing_id: attempt.job_listing_id,
  155. failed_step: attempt.failed_step,
  156. error_message: attempt.error_message,
  157. retry_count: attempt.retry_count
  158. }.to_json)
  159. return
  160. end
  161. current_retry_count = attempt.retry_count || 0
  162. if current_retry_count >= 3
  163. attempt.send_to_dlq!
  164. notify_admin_of_dlq(attempt)
  165. Rails.logger.error({
  166. event: "job_scraping_retry_sent_to_dlq",
  167. scraping_attempt_id: attempt.id,
  168. job_listing_id: attempt.job_listing_id,
  169. failed_step: attempt.failed_step,
  170. retry_count: current_retry_count
  171. }.to_json)
  172. else
  173. # Increment retry count
  174. attempt.update(retry_count: current_retry_count + 1)
  175. attempt.retry_attempt!
  176. ScrapeJobListingJob.perform_later(attempt.job_listing, scraping_attempt_id: attempt.id)
  177. Rails.logger.warn({
  178. event: "job_scraping_retry_rescheduled",
  179. scraping_attempt_id: attempt.id,
  180. job_listing_id: attempt.job_listing_id,
  181. failed_step: attempt.failed_step,
  182. retry_count: current_retry_count + 1
  183. }.to_json)
  184. end
  185. # Don't re-raise - we handle retries manually to prevent ActiveJob retry
  186. end
  187. # Notifies admin of items in the DLQ
  188. #
  189. # @param [ScrapingAttempt] attempt The failed attempt
  190. def notify_admin_of_dlq(attempt)
  191. # TODO: Implement admin notification
  192. # Could send email, Slack message, or other notification
  193. Rails.logger.info({
  194. event: "admin_notification_sent",
  195. scraping_attempt_id: attempt.id,
  196. job_listing_id: attempt.job_listing_id,
  197. notification_type: "dlq"
  198. }.to_json)
  199. end
  200. end

app/lib/exception_notifier.rb

0.0% lines covered

100.0% branches covered

103 relevant lines. 0 lines covered and 103 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Provides a wrapper for exception notifier systems.
  3. #
  4. # This class provides a centralized interface for exception handling,
  5. # supporting Sentry, Bugsnag, and email notifications with rich context.
  6. #
  7. # @example
  8. # ExceptionNotifier.notify(exception, {
  9. # context: 'ai_analysis',
  10. # ai_provider: 'openai',
  11. # analyzable_type: 'FeedbackPost',
  12. # analyzable_id: 123
  13. # })
  14. #
  15. class ExceptionNotifier
  16. class << self
  17. # Notify of an exception with context
  18. #
  19. # @param exception [Exception, Hash] The exception or error hash
  20. # @param options [Hash] Additional context and metadata
  21. # @option options [String] :context Error context (e.g., 'ai_analysis', 'payment')
  22. # @option options [String] :severity Severity level ('error', 'warning', 'info')
  23. # @option options [Hash] :user User information if available
  24. # @option options [Hash] :ai_context AI-specific metadata for AI errors
  25. def notify(exception, options = {})
  26. unless exception.is_a?(Exception)
  27. if exception.respond_to? :to_hash
  28. exception = exception.to_hash
  29. options.merge!(exception)
  30. message = options.delete(:error_message) || "error in #{options.delete(:error_class)}"
  31. exception = RuntimeError.new(message)
  32. else
  33. exception = RuntimeError.new("Unexpected error")
  34. end
  35. end
  36. payload = extract_i18n_context(exception)
  37. payload = extract_ai_context(options, payload) if options[:ai_context]
  38. if Rails.env.development?
  39. log_development_error(exception, options)
  40. else
  41. notify_exception(exception, options, payload)
  42. end
  43. end
  44. # Notify AI-specific errors with rich context
  45. #
  46. # @param exception [Exception] The exception
  47. # @param ai_context [Hash] AI-specific metadata
  48. # @option ai_context [String] :operation AI operation type (sentiment, entity, summary)
  49. # @option ai_context [String] :provider_name Provider name
  50. # @option ai_context [String] :model_identifier Model identifier
  51. # @option ai_context [Integer] :prompt_id Prompt ID
  52. # @option ai_context [String] :analyzable_type Type of content analyzed
  53. # @option ai_context [Integer] :analyzable_id ID of content analyzed
  54. # @option ai_context [Integer] :account_id Account ID
  55. def notify_ai_error(exception, ai_context = {})
  56. notify(exception, {
  57. context: "ai_#{ai_context[:operation]}",
  58. severity: ai_context[:severity] || "error",
  59. ai_context: ai_context,
  60. tags: {
  61. ai_operation: ai_context[:operation],
  62. ai_provider: ai_context[:provider_name],
  63. ai_model: ai_context[:model_identifier]
  64. }
  65. })
  66. end
  67. private
  68. def notify_exception(exception, options = {}, payload)
  69. notify_sentry(exception, options, payload) if Setting.sentry_enabled? && defined?(Sentry)
  70. notify_bugsnag(exception, options, payload) if Setting.bugsnag_enabled? && defined?(Bugsnag)
  71. end
  72. def notify_sentry(exception, options = {}, payload)
  73. return unless defined?(Sentry)
  74. Sentry.with_scope do |scope|
  75. scope.set_context("context", options)
  76. scope.set_context(:payload, payload) if payload.present?
  77. # Set AI-specific context if present
  78. scope.set_context("ai", options[:ai_context]) if options[:ai_context]
  79. # Set tags (used for AI and non-AI contexts like billing)
  80. scope.set_tags(options[:tags]) if options[:tags].present?
  81. # Set severity level
  82. scope.set_level(options[:severity]) if options[:severity]
  83. # Set user context
  84. if defined?(Current) && Current.respond_to?(:user) && Current.user.present?
  85. current_email = Current.user.try(:email_address) || Current.user.try(:email)
  86. scope.set_user(email: current_email, id: Current.user.id)
  87. elsif options[:user].present?
  88. scope.set_user(email: options[:user][:email], id: options[:user][:id])
  89. end
  90. Sentry.capture_exception(exception)
  91. end
  92. end
  93. def notify_bugsnag(exception, options = {}, payload)
  94. return unless defined?(Bugsnag)
  95. if payload.blank?
  96. return Bugsnag.notify(exception) do |event|
  97. options.each { |option, data| event.add_metadata(option, data) }
  98. end
  99. end
  100. Bugsnag.notify(exception) do |event|
  101. event.add_metadata(:context, payload)
  102. options.each { |option, data| event.add_metadata(option, data) }
  103. end
  104. end
  105. def extract_i18n_context(exception, payload = {})
  106. return payload unless exception.is_a?(::I18n::MissingTranslationData)
  107. payload[:translation] = exception&.key
  108. payload[:translation_options] = exception&.options
  109. payload
  110. end
  111. # Extract AI-specific context from options
  112. #
  113. # @param options [Hash] Options hash with ai_context
  114. # @param payload [Hash] Existing payload
  115. # @return [Hash] Enhanced payload with AI context
  116. def extract_ai_context(options, payload = {})
  117. ai_ctx = options[:ai_context] || {}
  118. payload[:ai_operation] = ai_ctx[:operation]
  119. payload[:ai_provider] = ai_ctx[:provider_name]
  120. payload[:ai_model] = ai_ctx[:model_identifier]
  121. payload[:ai_prompt_id] = ai_ctx[:prompt_id]
  122. payload[:analyzable_type] = ai_ctx[:analyzable_type]
  123. payload[:analyzable_id] = ai_ctx[:analyzable_id]
  124. payload[:account_id] = ai_ctx[:account_id]
  125. payload[:tokens_used] = ai_ctx[:tokens_used]
  126. payload[:processing_time_ms] = ai_ctx[:processing_time_ms]
  127. payload.compact
  128. end
  129. # Log error in development with better formatting
  130. #
  131. # @param exception [Exception] The exception
  132. # @param options [Hash] Additional context
  133. def log_development_error(exception, options)
  134. Rails.logger.error "\n" + ("=" * 80)
  135. Rails.logger.error "EXCEPTION: #{exception.class}"
  136. Rails.logger.error "MESSAGE: #{exception.message}"
  137. Rails.logger.error "CONTEXT: #{options[:context]}" if options[:context]
  138. if options[:ai_context]
  139. Rails.logger.error "AI OPERATION: #{options[:ai_context][:operation]}"
  140. Rails.logger.error "AI PROVIDER: #{options[:ai_context][:provider_name]}"
  141. Rails.logger.error "AI MODEL: #{options[:ai_context][:model_identifier]}"
  142. end
  143. Rails.logger.error "\nBACKTRACE:"
  144. Rails.logger.error exception.backtrace&.first(10)&.join("\n")
  145. Rails.logger.error "OPTIONS: #{options.except(:ai_context)}" if options.any?
  146. Rails.logger.error "=" * 80 + "\n"
  147. end
  148. end
  149. end

app/mailers/application_mailer.rb

0.0% lines covered

100.0% branches covered

12 relevant lines. 0 lines covered and 12 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class ApplicationMailer < ActionMailer::Base
  2. default from: "Gleania <noreply@gleania.com>"
  3. layout "mailer"
  4. # Attach logo as inline attachment for all emails
  5. before_action :attach_logo
  6. private
  7. def attach_logo
  8. logo_path = Rails.root.join("app/assets/images/logo/logo.png")
  9. if File.exist?(logo_path)
  10. attachments.inline["logo.png"] = File.read(logo_path)
  11. end
  12. end
  13. end

app/mailers/connected_account_mailer.rb

0.0% lines covered

100.0% branches covered

10 relevant lines. 0 lines covered and 10 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Mailer for connected account notifications
  3. class ConnectedAccountMailer < ApplicationMailer
  4. # Sends a notification when a connected account needs reauthorization
  5. #
  6. # @param connected_account [ConnectedAccount] The account that needs reauth
  7. def reauth_required(connected_account)
  8. @account = connected_account
  9. @user = connected_account.user
  10. mail(
  11. to: @user.email_address,
  12. subject: "Action Required: Reconnect your Gmail account"
  13. )
  14. end
  15. end

app/mailers/passwords_mailer.rb

0.0% lines covered

100.0% branches covered

6 relevant lines. 0 lines covered and 6 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class PasswordsMailer < ApplicationMailer
  2. def reset(user)
  3. @user = user
  4. mail subject: "Reset your password", to: user.email_address
  5. end
  6. end

app/mailers/user_mailer.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class UserMailer < ApplicationMailer
  2. # Sends email verification link to user
  3. # @param user [User] The user to send verification to
  4. def verify_email(user)
  5. @user = user
  6. @verification_url = email_verification_url(@user.generate_token_for(:email_verification))
  7. mail(
  8. to: user.email_address,
  9. subject: "Verify your Gleania account"
  10. )
  11. end
  12. # Sends welcome email after user verifies their email
  13. # @param user [User] The newly verified user
  14. def welcome(user)
  15. @user = user
  16. mail(
  17. to: user.email_address,
  18. subject: "Welcome to Gleania! 🎉"
  19. )
  20. end
  21. end

app/models/ai.rb

100.0% lines covered

100.0% branches covered

1 relevant lines. 1 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Module namespace for AI-related models
  3. #
  4. # Contains:
  5. # - Ai::LlmPrompt (STI base for prompt templates)
  6. # - Ai::JobExtractionPrompt (job listing extraction prompts)
  7. # - Ai::EmailExtractionPrompt (recruiter email extraction prompts)
  8. # - Ai::ResumeSkillExtractionPrompt (resume skill extraction prompts)
  9. # - Ai::LlmApiLog (API call logging)
  10. #
  11. 1 module Ai
  12. end

app/models/ai/assistant_memory_proposal_prompt.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for proposing long-term user memories (always user-confirmed).
  4. #
  5. # Variables:
  6. # - {{messages}}
  7. class AssistantMemoryProposalPrompt < LlmPrompt
  8. def self.default_prompt_template
  9. <<~PROMPT
  10. You are extracting durable user preferences/goals/constraints that should be remembered across chats.
  11. Only propose items that are explicitly stated by the user. Do not infer sensitive attributes.
  12. Output JSON only:
  13. {
  14. "items": [
  15. { "key": "string", "value": { }, "reason": "string", "confidence": 0.0 }
  16. ]
  17. }
  18. Keys should be stable and namespaced (examples):
  19. - preferences.tone
  20. - goals.target_role
  21. - constraints.timezone
  22. - preferences.focus_areas
  23. Recent messages:
  24. {{messages}}
  25. PROMPT
  26. end
  27. def self.default_system_prompt
  28. <<~PROMPT
  29. You are extracting durable user preferences/goals/constraints that should be remembered across chats.
  30. Only propose items that are explicitly stated by the user. Do not infer sensitive attributes.
  31. Return only valid JSON. Do not include markdown or extra commentary.
  32. PROMPT
  33. end
  34. def self.default_variables
  35. {
  36. "messages" => { "required" => true, "description" => "Recent messages to extract explicit preferences/goals from" }
  37. }
  38. end
  39. end
  40. end

app/models/ai/assistant_system_prompt.rb

0.0% lines covered

100.0% branches covered

36 relevant lines. 0 lines covered and 36 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # System prompt for the in-app Assistant.
  4. #
  5. # Unlike extraction prompts (which use prompt_template for user content with variables),
  6. # the assistant chat uses:
  7. # - system_prompt: LLM behavior instructions (role, rules, formatting)
  8. # - No prompt_template variables - the user's question IS the content
  9. #
  10. # The full system prompt sent to the LLM is built by LlmResponder as:
  11. #
  12. # [System Prompt] + [User Context Section]
  13. #
  14. # Where User Context is dynamically injected and includes:
  15. # - User profile (name, account age)
  16. # - Career context (resume summary, work history, career targets)
  17. # - Skills summary (top skills)
  18. # - Pipeline status (application count, recent applications)
  19. # - Page context (current page the user is viewing)
  20. #
  21. # The context is built by Assistant::Context::Builder and formatted
  22. # by LlmResponder.format_context_for_prompt before being appended.
  23. #
  24. # @see Assistant::Context::Builder
  25. # @see Assistant::Chat::Components::LlmResponder#build_system_prompt_with_context
  26. class AssistantSystemPrompt < LlmPrompt
  27. # System prompt defining the assistant's behavior, rules, and formatting.
  28. # This is sent as the system message to the LLM.
  29. #
  30. # @return [String] Default system prompt
  31. def self.default_system_prompt
  32. <<~PROMPT
  33. You are Gleania, an intelligent assistant embedded inside the Gleania web app which helps the user with their job search, interview tracking & preparation, gathering feedback across interviews, skill analysis and career development.
  34. You help the user with:
  35. - interview preparation and debriefs
  36. - understanding their skill profile and gaps
  37. - analyzing job listings and fit
  38. - organizing and updating their pipeline
  39. - providing insights and recommendations for career development
  40. Rules:
  41. - Use the USER CONTEXT section below to personalize your responses.
  42. - If needed data is missing from context, ask a clarifying question.
  43. - Never claim you executed an action unless the system explicitly confirms it.
  44. - Use tools when they help you answer with up-to-date or user-specific data.
  45. - For write actions, only proceed after explicit user confirmation in the UI.
  46. - Keep responses concise, structured, and actionable.
  47. - When discussing the user's resume, work history, or skills, reference the specific details from their context.
  48. Formatting:
  49. - Use **Markdown** for your responses.
  50. - Use headers (##, ###) to organize longer responses.
  51. - Use bullet points (-) or numbered lists for steps and options.
  52. - Use **bold** for emphasis and `inline code` for technical terms.
  53. - Use fenced code blocks with language identifiers for code examples.
  54. - Keep paragraphs short and readable.
  55. PROMPT
  56. end
  57. # Prompt template - not used for assistant chat since user provides their own question.
  58. # This exists for DB schema compatibility with LlmPrompt base class.
  59. #
  60. # @return [String] Empty prompt template
  61. def self.default_prompt_template
  62. "Assistant chat - user provides the message content directly."
  63. end
  64. def self.default_variables
  65. {}
  66. end
  67. end
  68. end

app/models/ai/assistant_thread_summary_prompt.rb

0.0% lines covered

100.0% branches covered

35 relevant lines. 0 lines covered and 35 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for summarizing an assistant thread.
  4. #
  5. # Variables:
  6. # - {{existing_summary}}
  7. # - {{messages}}
  8. class AssistantThreadSummaryPrompt < LlmPrompt
  9. def self.default_prompt_template
  10. <<~PROMPT
  11. You are summarizing an assistant chat thread. Produce a concise summary that preserves:
  12. - user goals and constraints
  13. - decisions and commitments
  14. - key context needed to continue the conversation
  15. Existing summary (may be empty):
  16. {{existing_summary}}
  17. New messages (role and content):
  18. {{messages}}
  19. Output only the updated summary text.
  20. PROMPT
  21. end
  22. def self.default_system_prompt
  23. <<~PROMPT
  24. You are summarizing an assistant chat thread. Your goal is to produce a concise summary that preserves:
  25. - user goals and constraints
  26. - decisions and commitments
  27. - key context needed to continue the conversation
  28. You should only return the updated summary text.
  29. Do not include markdown or extra commentary.
  30. Do not include any other text or formatting.
  31. Do not include any other text or formatting.
  32. PROMPT
  33. end
  34. def self.default_variables
  35. {
  36. "existing_summary" => { "required" => false, "description" => "Current summary text" },
  37. "messages" => { "required" => true, "description" => "Recent messages to incorporate" }
  38. }
  39. end
  40. end
  41. end

app/models/ai/email_extraction_prompt.rb

0.0% lines covered

100.0% branches covered

58 relevant lines. 0 lines covered and 58 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for extracting job opportunity data from recruiter emails
  4. #
  5. # Used by Opportunities::ExtractionService to extract structured opportunity
  6. # data from email content (subject + body).
  7. #
  8. # Variables:
  9. # - {{subject}} - The email subject line
  10. # - {{body}} - The email body content
  11. #
  12. class EmailExtractionPrompt < LlmPrompt
  13. # Default prompt template for email extraction
  14. #
  15. # @return [String] Default prompt template
  16. def self.default_prompt_template
  17. <<~PROMPT
  18. Analyze the following email content and extract structured information about the job opportunity.
  19. The email may be a direct message from a recruiter, a forwarded or a direct message from LinkedIn, or a referral.
  20. EMAIL SUBJECT: {{subject}}
  21. EMAIL CONTENT:
  22. {{body}}
  23. Extract the following information and respond with a JSON object:
  24. {
  25. "company_name": "The company with the job opening (not the recruiting agency unless they are the employer)",
  26. "company_domain": "The industry/domain the company operates in (e.g., 'FinTech', 'SaaS', 'Healthcare', 'E-commerce', 'EdTech', 'AI/ML', 'Cybersecurity', 'Enterprise Software', 'B2B', 'B2C', etc. - use null if unclear)",
  27. "job_role_title": "The job title or role being offered",
  28. "job_role_department": "The department/function this role belongs to (one of: 'Engineering', 'Product', 'Design', 'Data Science', 'DevOps/SRE', 'Sales', 'Marketing', 'Customer Success', 'Finance', 'HR/People', 'Legal', 'Operations', 'Executive', 'Research', 'QA/Testing', 'Security', 'IT', 'Content', 'Other')",
  29. "job_url": "URL to the job listing or application page (if found in the email)",
  30. "all_links": [
  31. {"url": "...", "type": "job_posting|company_website|calendar|linkedin|other", "description": "brief description"}
  32. ],
  33. "recruiter_info": {
  34. "name": "Recruiter's name",
  35. "title": "Recruiter's job title",
  36. "company": "Recruiting company or agency name"
  37. },
  38. "key_details": "A brief summary of important details like: location, remote/hybrid/onsite, salary range, tech stack, company stage, team size, etc.",
  39. "is_forwarded": true/false,
  40. "original_source": "linkedin|email|referral|other",
  41. "confidence_score": 0.0 to 1.0, # Confidence score for the overall extraction
  42. "potential_opportunity": true/false, # Whether this is a potential opportunity or just a generic/newletter type email
  43. "potential_opportunity_confidence_score": 0.0 to 1.0, # Confidence score for the potential opportunity
  44. "potential_opportunity_confidence_reasoning": "The reasoning for why this is a potential opportunity or not, if potential_opportunity is false, this should be null"
  45. }
  46. Guidelines:
  47. - If information is not clearly stated, use null instead of guessing
  48. - For job_url, only include URLs that lead to job listings or application pages
  49. - Distinguish between the hiring company and any recruiting agency
  50. - Look for LinkedIn message indicators, forwarded email markers
  51. - Extract all relevant links even if they're not the main job posting
  52. - confidence_score should reflect how confident you are in the extracted information
  53. Respond ONLY with the JSON object, no additional text.
  54. PROMPT
  55. end
  56. def self.default_system_prompt
  57. <<~PROMPT
  58. You are an expert at extracting structured job opportunity information from recruiter emails.
  59. Your goal is to return only valid JSON. Do not guess missing values; use null.
  60. Do not include markdown or extra commentary.
  61. Do not include any other text or formatting.
  62. PROMPT
  63. end
  64. # Returns the expected variables for this prompt type
  65. #
  66. # @return [Hash] Variable definitions
  67. def self.default_variables
  68. {
  69. "subject" => { "required" => true, "description" => "The email subject line" },
  70. "body" => { "required" => true, "description" => "The email body content" }
  71. }
  72. end
  73. end
  74. end

app/models/ai/email_facts_extraction_prompt.rb

0.0% lines covered

100.0% branches covered

90 relevant lines. 0 lines covered and 90 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for extracting EmailFacts (unified workflow facts) from emails.
  4. #
  5. # Used by Signals::Facts::EmailFactsExtractor.
  6. #
  7. # Variables:
  8. # - {{subject}}
  9. # - {{body}}
  10. # - {{from_email}}
  11. # - {{from_name}}
  12. # - {{email_type}} (legacy classifier hint, optional)
  13. # - {{application_snapshot}} (JSON string, optional)
  14. class EmailFactsExtractionPrompt < LlmPrompt
  15. def self.default_prompt_template
  16. <<~PROMPT
  17. You are extracting workflow facts from a recruiting/interview email.
  18. You MUST NOT guess. If information is not explicitly present, use null/false/empty.
  19. FROM: {{from_name}} <{{from_email}}>
  20. SUBJECT: {{subject}}
  21. LEGACY_EMAIL_TYPE_HINT: {{email_type}}
  22. APPLICATION SNAPSHOT (may be null):
  23. {{application_snapshot}}
  24. EMAIL BODY (canonicalized):
  25. {{body}}
  26. Return ONLY valid JSON matching this shape:
  27. {
  28. "extraction": { "provider": null, "model": null, "confidence": 0.0, "warnings": [] },
  29. "classification": { "kind": "scheduling|interview_invite|round_feedback|status_update|application_confirmation|recruiter_outreach|interview_assessment|other|unknown", "confidence": 0.0, "evidence": ["..."] },
  30. "entities": {
  31. "company": { "name": null, "website": null },
  32. "recruiter": { "name": null, "email": null, "title": null },
  33. "job": { "title": null, "department": null, "location": null, "url": null }
  34. },
  35. "action_links": [{ "url": "...", "action_label": "...", "priority": 1 }],
  36. "key_insights": null,
  37. "is_forwarded": false,
  38. "scheduling": {
  39. "is_scheduling_related": false,
  40. "scheduled_at": null,
  41. "timezone_hint": null,
  42. "duration_minutes": 0,
  43. "stage": null,
  44. "round_type": null,
  45. "stage_name": null,
  46. "interviewer_name": null,
  47. "interviewer_role": null,
  48. "video_link": null,
  49. "phone_number": null,
  50. "location": null,
  51. "is_rescheduled": false,
  52. "is_cancelled": false,
  53. "original_scheduled_at": null,
  54. "evidence": []
  55. },
  56. "round_feedback": {
  57. "has_round_feedback": false,
  58. "result": null,
  59. "stage_mentioned": null,
  60. "round_type": null,
  61. "interviewer_mentioned": null,
  62. "date_mentioned": null,
  63. "feedback": { "has_detailed_feedback": false, "summary": null, "strengths": [], "improvements": [], "full_feedback_text": null },
  64. "next_steps": { "has_next_round": false, "next_round_type": null, "next_round_hint": null, "timeline_hint": null },
  65. "evidence": []
  66. },
  67. "status_change": {
  68. "has_status_change": false,
  69. "type": "rejection|offer|withdrawal|ghosted|on_hold|no_change|null",
  70. "is_final": null,
  71. "effective_date": null,
  72. "rejection_details": { "reason": null, "stage_rejected_at": null, "is_generic": false, "door_open": false },
  73. "offer_details": { "role_title": null, "department": null, "start_date": null, "response_deadline": null, "includes_compensation_info": false, "compensation_hints": null, "next_steps": null },
  74. "feedback": { "has_feedback": false, "feedback_text": null, "is_constructive": false },
  75. "evidence": []
  76. }
  77. }
  78. Rules:
  79. - Output ONLY JSON, no markdown, no commentary.
  80. - Every evidence string MUST be a direct substring from the email body or subject.
  81. - Include only up to 20 action_links. Prioritize schedule/join/apply links.
  82. PROMPT
  83. end
  84. def self.default_system_prompt
  85. <<~PROMPT
  86. You extract structured facts for an email-driven interview workflow.
  87. Be conservative. Do not infer hidden state. Do not guess.
  88. Return only valid JSON.
  89. PROMPT
  90. end
  91. def self.default_variables
  92. {
  93. "subject" => { "required" => true },
  94. "body" => { "required" => true },
  95. "from_email" => { "required" => false },
  96. "from_name" => { "required" => false },
  97. "email_type" => { "required" => false },
  98. "application_snapshot" => { "required" => false }
  99. }
  100. end
  101. end
  102. end

app/models/ai/interview_extraction_prompt.rb

0.0% lines covered

100.0% branches covered

91 relevant lines. 0 lines covered and 91 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for extracting interview details from scheduling confirmation emails
  4. #
  5. # Used by Signals::InterviewRoundProcessor to extract structured interview data
  6. # from confirmation emails (Calendly, GoodTime, manual, etc.)
  7. #
  8. # Variables:
  9. # - {{subject}} - The email subject line
  10. # - {{body}} - The email body content
  11. # - {{from_email}} - The sender's email address
  12. # - {{from_name}} - The sender's display name
  13. # - {{company_name}} - The company name if known
  14. #
  15. class InterviewExtractionPrompt < LlmPrompt
  16. # Default prompt template for interview extraction
  17. #
  18. # @return [String] Default prompt template
  19. def self.default_prompt_template
  20. <<~PROMPT
  21. Analyze the following interview scheduling/confirmation email and extract interview details.
  22. FROM: {{from_name}} <{{from_email}}>
  23. SUBJECT: {{subject}}
  24. COMPANY: {{company_name}}
  25. EMAIL CONTENT:
  26. {{body}}
  27. Extract the following information and respond with a JSON object:
  28. {
  29. "interview": {
  30. "scheduled_at": "ISO 8601 datetime (e.g., '2026-01-21T14:00:00-08:00') - MUST include timezone offset",
  31. "duration_minutes": 30 or 45 or 60 (integer, extract from email or default to 30),
  32. "timezone": "Timezone name (e.g., 'PST', 'EST', 'UTC') if mentioned",
  33. "stage": "screening|technical|hiring_manager|culture_fit|other",
  34. "stage_name": "Custom stage name if mentioned (e.g., 'Technical Round 1', 'Final Interview')"
  35. },
  36. "interviewer": {
  37. "name": "Full name of the interviewer",
  38. "role": "Job title/role of the interviewer (e.g., 'Engineering Manager', 'Senior Recruiter')",
  39. "email": "Interviewer's email if mentioned"
  40. },
  41. "logistics": {
  42. "video_link": "Full URL to video conference (Zoom, Meet, Teams, etc.)",
  43. "phone_number": "Phone number if it's a phone interview",
  44. "location": "Physical location if in-person interview",
  45. "meeting_id": "Meeting ID/code if provided separately from link",
  46. "passcode": "Meeting passcode if provided"
  47. },
  48. "confirmation_source": "calendly|goodtime|greenhouse|lever|manual|other",
  49. "is_rescheduled": true/false,
  50. "is_cancelled": true/false,
  51. "original_scheduled_at": "ISO 8601 datetime if this is a reschedule",
  52. "additional_instructions": "Any prep instructions, what to bring, who to ask for, etc.",
  53. "confidence_score": 0.0 to 1.0
  54. }
  55. Guidelines for stage detection:
  56. - "screening" - Initial recruiter call, HR screen, phone screen, intro call
  57. - "technical" - Coding interview, system design, technical assessment, live coding
  58. - "hiring_manager" - Meeting with manager, team lead, direct supervisor
  59. - "culture_fit" - Values interview, behavioral, team fit, culture chat
  60. - "other" - Final round, panel, presentation, case study, on-site
  61. Guidelines for confirmation_source:
  62. - "calendly" - Calendly scheduling links/confirmations
  63. - "goodtime" - GoodTime scheduling platform
  64. - "greenhouse" - Greenhouse ATS confirmations
  65. - "lever" - Lever ATS confirmations
  66. - "manual" - Direct email from recruiter/company (no scheduling platform)
  67. - "other" - Other scheduling tools
  68. Guidelines for date/time extraction:
  69. - Always include timezone offset in scheduled_at (e.g., -08:00 for PST)
  70. - If no timezone specified, use the timezone mentioned in the email or default to UTC
  71. - Parse various date formats: "Tuesday, January 21st", "1/21/26", "21 Jan 2026"
  72. - Parse various time formats: "2:00 PM", "14:00", "2pm PST"
  73. Guidelines for video links:
  74. - Extract the full video conference URL
  75. - Common patterns: zoom.us/j/, meet.google.com/, teams.microsoft.com/
  76. Use null for any field where information is not clearly available.
  77. Respond ONLY with the JSON object, no additional text or markdown.
  78. PROMPT
  79. end
  80. # Default system prompt for interview extraction
  81. #
  82. # @return [String]
  83. def self.default_system_prompt
  84. <<~PROMPT
  85. You are an expert at extracting interview scheduling details from confirmation emails.
  86. Your goal is to accurately extract:
  87. - When the interview is scheduled (date, time, timezone)
  88. - How long it will last
  89. - Who the interviewer is
  90. - How to join (video link, phone, location)
  91. - What type/stage of interview it is
  92. Rules:
  93. - Return ONLY valid JSON, no markdown or commentary
  94. - Use null for missing information, never guess
  95. - Always include timezone in scheduled_at datetime
  96. - Be precise with video conference URLs
  97. - Detect rescheduling and cancellation language
  98. PROMPT
  99. end
  100. # Returns the expected variables for this prompt type
  101. #
  102. # @return [Hash] Variable definitions
  103. def self.default_variables
  104. {
  105. "subject" => { "required" => true, "description" => "The email subject line" },
  106. "body" => { "required" => true, "description" => "The email body content" },
  107. "from_email" => { "required" => true, "description" => "The sender's email address" },
  108. "from_name" => { "required" => false, "description" => "The sender's display name" },
  109. "company_name" => { "required" => false, "description" => "The company name if known" }
  110. }
  111. end
  112. end
  113. end

app/models/ai/interview_prep_focus_areas_prompt.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for generating focused prep areas for an interview.
  4. #
  5. # Variables:
  6. # - {{candidate_profile}}
  7. # - {{job_context}}
  8. # - {{interview_stage}}
  9. # - {{feedback_themes}}
  10. class InterviewPrepFocusAreasPrompt < LlmPrompt
  11. def self.default_prompt_template
  12. <<~PROMPT
  13. You are Gleania, a calm, practical interview preparation coach.
  14. You MUST NOT invent experience. If unknown, say "unknown" and avoid specifics.
  15. TASK:
  16. Generate 3-5 focused preparation areas for this specific role and interview stage.
  17. For each item, provide: why it matters, how to prepare, and what experiences to use (only if inferable from profile).
  18. CANDIDATE_PROFILE_JSON:
  19. {{candidate_profile}}
  20. JOB_CONTEXT_JSON:
  21. {{job_context}}
  22. INTERVIEW_STAGE:
  23. {{interview_stage}}
  24. FEEDBACK_THEMES_JSON:
  25. {{feedback_themes}}
  26. OUTPUT JSON ONLY:
  27. {
  28. "focus_areas": [
  29. {
  30. "title": "Short actionable title",
  31. "why_it_matters": "1-2 sentences",
  32. "how_to_prepare": ["bullet", "bullet"],
  33. "experiences_to_use": ["bullet", "bullet"]
  34. }
  35. ]
  36. }
  37. PROMPT
  38. end
  39. def self.default_system_prompt
  40. <<~PROMPT
  41. You are Gleania, a calm, practical interview preparation coach.
  42. Focus on actionable preparation guidance. Do not invent experience.
  43. PROMPT
  44. end
  45. def self.default_variables
  46. {
  47. "candidate_profile" => { "required" => true, "description" => "Candidate profile JSON" },
  48. "job_context" => { "required" => true, "description" => "Job context JSON" },
  49. "interview_stage" => { "required" => true, "description" => "Interview stage label" },
  50. "feedback_themes" => { "required" => false, "description" => "Feedback themes JSON" }
  51. }
  52. end
  53. end
  54. end

app/models/ai/interview_prep_match_prompt.rb

0.0% lines covered

100.0% branches covered

44 relevant lines. 0 lines covered and 44 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for generating interview prep match analysis.
  4. #
  5. # Variables:
  6. # - {{candidate_profile}} - Structured candidate profile summary (JSON)
  7. # - {{job_context}} - Job listing context and text (JSON)
  8. # - {{interview_stage}} - Interview stage label (string)
  9. # - {{feedback_themes}} - Prior feedback themes (JSON)
  10. class InterviewPrepMatchPrompt < LlmPrompt
  11. def self.default_prompt_template
  12. <<~PROMPT
  13. You are Gleania, a calm, practical interview preparation coach.
  14. You MUST NOT invent experience. If something is unknown, say so.
  15. TASK:
  16. Given the candidate profile and the job context, produce a qualitative match analysis.
  17. Avoid numeric scoring. Use one label only: "strong_match", "partial_match", or "stretch_role".
  18. CANDIDATE_PROFILE_JSON:
  19. {{candidate_profile}}
  20. JOB_CONTEXT_JSON:
  21. {{job_context}}
  22. INTERVIEW_STAGE:
  23. {{interview_stage}}
  24. FEEDBACK_THEMES_JSON:
  25. {{feedback_themes}}
  26. OUTPUT JSON ONLY (no markdown, no extra text):
  27. {
  28. "match_label": "strong_match|partial_match|stretch_role",
  29. "strong_in": ["..."],
  30. "partial_in": ["..."],
  31. "missing_or_risky": ["..."],
  32. "notes": "1-3 sentences, grounded in provided data only"
  33. }
  34. PROMPT
  35. end
  36. def self.default_system_prompt
  37. <<~PROMPT
  38. You are Gleania, a calm, practical interview preparation coach.
  39. Be concise, accurate, and grounded in the provided data.
  40. Never invent experience; if unknown, say so.
  41. PROMPT
  42. end
  43. def self.default_variables
  44. {
  45. "candidate_profile" => { "required" => true, "description" => "Candidate profile JSON" },
  46. "job_context" => { "required" => true, "description" => "Job context JSON" },
  47. "interview_stage" => { "required" => true, "description" => "Interview stage label" },
  48. "feedback_themes" => { "required" => false, "description" => "Feedback themes JSON" }
  49. }
  50. end
  51. end
  52. end

app/models/ai/interview_prep_question_framing_prompt.rb

0.0% lines covered

100.0% branches covered

48 relevant lines. 0 lines covered and 48 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for generating contextual question framing guidance.
  4. #
  5. # Variables:
  6. # - {{candidate_profile}}
  7. # - {{job_context}}
  8. # - {{interview_stage}}
  9. # - {{feedback_themes}}
  10. class InterviewPrepQuestionFramingPrompt < LlmPrompt
  11. def self.default_prompt_template
  12. <<~PROMPT
  13. You are Gleania, a calm interview preparation coach.
  14. You MUST NOT invent experience.
  15. Do NOT write full scripted answers. Provide framing and outlines only.
  16. TASK:
  17. Provide 6-10 common questions for this role/stage, and how this candidate should FRAME their answers.
  18. Include: framing bullets, a suggested outline, and common pitfalls.
  19. CANDIDATE_PROFILE_JSON:
  20. {{candidate_profile}}
  21. JOB_CONTEXT_JSON:
  22. {{job_context}}
  23. INTERVIEW_STAGE:
  24. {{interview_stage}}
  25. FEEDBACK_THEMES_JSON:
  26. {{feedback_themes}}
  27. OUTPUT JSON ONLY:
  28. {
  29. "questions": [
  30. {
  31. "question": "…",
  32. "framing": ["bullet", "bullet"],
  33. "outline": ["bullet", "bullet"],
  34. "pitfalls": ["bullet", "bullet"]
  35. }
  36. ]
  37. }
  38. PROMPT
  39. end
  40. def self.default_system_prompt
  41. <<~PROMPT
  42. You are Gleania, a calm interview preparation coach.
  43. Provide framing and outlines, not full scripted answers.
  44. Never invent experience; do not claim specifics not in the profile.
  45. PROMPT
  46. end
  47. def self.default_variables
  48. {
  49. "candidate_profile" => { "required" => true, "description" => "Candidate profile JSON" },
  50. "job_context" => { "required" => true, "description" => "Job context JSON" },
  51. "interview_stage" => { "required" => true, "description" => "Interview stage label" },
  52. "feedback_themes" => { "required" => false, "description" => "Feedback themes JSON" }
  53. }
  54. end
  55. end
  56. end

app/models/ai/interview_prep_strength_positioning_prompt.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for generating strength positioning guidance.
  4. #
  5. # Variables:
  6. # - {{candidate_profile}}
  7. # - {{job_context}}
  8. # - {{interview_stage}}
  9. # - {{feedback_themes}}
  10. class InterviewPrepStrengthPositioningPrompt < LlmPrompt
  11. def self.default_prompt_template
  12. <<~PROMPT
  13. You are Gleania, a calm interview preparation coach.
  14. You MUST NOT invent experience. Avoid claims without evidence.
  15. TASK:
  16. Identify 4-6 strengths the candidate should emphasize for this role and stage.
  17. Each strength should include a positioning note and suggested evidence types (not fake examples).
  18. CANDIDATE_PROFILE_JSON:
  19. {{candidate_profile}}
  20. JOB_CONTEXT_JSON:
  21. {{job_context}}
  22. INTERVIEW_STAGE:
  23. {{interview_stage}}
  24. FEEDBACK_THEMES_JSON:
  25. {{feedback_themes}}
  26. OUTPUT JSON ONLY:
  27. {
  28. "strengths": [
  29. {
  30. "title": "Strength to emphasize",
  31. "positioning": "How to frame it in the interview (1-2 sentences)",
  32. "evidence_types": ["project impact", "trade-offs", "ownership", "mentorship"]
  33. }
  34. ]
  35. }
  36. PROMPT
  37. end
  38. def self.default_system_prompt
  39. <<~PROMPT
  40. You are Gleania, a calm interview preparation coach.
  41. Help the candidate position real strengths credibly; avoid over-claiming.
  42. Never invent experience.
  43. PROMPT
  44. end
  45. def self.default_variables
  46. {
  47. "candidate_profile" => { "required" => true, "description" => "Candidate profile JSON" },
  48. "job_context" => { "required" => true, "description" => "Job context JSON" },
  49. "interview_stage" => { "required" => true, "description" => "Interview stage label" },
  50. "feedback_themes" => { "required" => false, "description" => "Feedback themes JSON" }
  51. }
  52. end
  53. end
  54. end

app/models/ai/job_extraction_prompt.rb

0.0% lines covered

100.0% branches covered

62 relevant lines. 0 lines covered and 62 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for extracting job listing data from HTML
  4. #
  5. # Used by Scraping::AiJobExtractorService to extract structured job data
  6. # from scraped HTML content.
  7. #
  8. # Variables:
  9. # - {{url}} - The job listing URL
  10. # - {{html_content}} - The cleaned HTML content
  11. #
  12. class JobExtractionPrompt < LlmPrompt
  13. # Default prompt template for job extraction
  14. #
  15. # @return [String] Default prompt template
  16. def self.default_prompt_template
  17. <<~PROMPT
  18. Extract the following information from this job listing HTML and return it as JSON:
  19. Required fields:
  20. - title: Job title
  21. - company: Company name (the organization posting the job)
  22. - company_domain: The industry/domain the company operates in (e.g., "FinTech", "SaaS", "Healthcare", "E-commerce", "EdTech", "AI/ML", "Cybersecurity", "Gaming", "Social Media", "Enterprise Software", "B2B", "B2C", "Marketplace", "Media/Entertainment", "Real Estate", "Travel", "Logistics", "Automotive", "CleanTech", "Biotech", "Other" - use null if unclear)
  23. - job_role: Job role/title (can be the same as title or a normalized version)
  24. - job_role_department: The department/function this role belongs to (one of: "Engineering", "Product", "Design", "Data Science", "DevOps/SRE", "Sales", "Marketing", "Customer Success", "Finance", "HR/People", "Legal", "Operations", "Executive", "Research", "QA/Testing", "Security", "IT", "Content", "Other")
  25. - job_board: The job board where the job listing was found (e.g. "LinkedIn", "Greenhouse", "Lever", "Indeed", "Glassdoor", "Workable", "Jobvite", "ICIMS", "SmartRecruiters", "BambooHR", "AshbyHQ", "Other")
  26. - description: Brief summary of the role (1-2 sentences)
  27. - description_markdown: Complete job posting formatted as clean Markdown (see format below)
  28. - requirements: Array of requirement strings (one item per requirement)
  29. - responsibilities: Array of responsibility strings (one item per responsibility)
  30. - location: Office location or "Remote"
  31. - remote_type: one of "on_site", "hybrid", or "remote"
  32. Optional fields (use null if not found):
  33. - about_company: A concise "About the company" section (mission/product context)
  34. - company_culture: Company values/culture section (how they work, principles, DEI, etc.)
  35. - salary_min: Minimum salary as number
  36. - salary_max: Maximum salary as number
  37. - salary_currency: Currency code (e.g., "USD", "EUR")
  38. - compensation_text: Human-readable compensation description (e.g., "$120,000 - $150,000 USD per year")
  39. - equity_info: Stock options or equity details
  40. - benefits: Array of benefit strings
  41. - perks: Array of perk strings
  42. - interview_process: Description of the interview/hiring process if mentioned
  43. - custom_sections: Any additional structured data as a JSON object
  44. Also provide:
  45. - confidence_score: Your confidence in the extraction accuracy (0.0 to 1.0)
  46. - notes: Any extraction challenges or uncertainties
  47. MARKDOWN FORMAT for description_markdown:
  48. - Use ## for main sections (e.g., ## About the Role, ## Responsibilities, ## Requirements)
  49. - Use ### for subsections
  50. - Use - for bullet lists (not *)
  51. - Use **text** for bold emphasis
  52. - Use *text* for italic emphasis
  53. - DO NOT include any HTML tags
  54. - Preserve the logical structure of the original posting
  55. - Include all content: about company, role description, responsibilities, requirements, benefits, etc.
  56. Job Listing URL: {{url}}
  57. HTML Content:
  58. {{html_content}}
  59. Return only valid JSON with no additional commentary.
  60. PROMPT
  61. end
  62. def self.default_system_prompt
  63. <<~PROMPT
  64. You are an expert at extracting structured job listing data from HTML. You are given a job listing URL and the HTML content of the job listing. You need to extract the structured data from the HTML content.
  65. For description_markdown, produce clean, well-formatted Markdown that preserves the job posting's structure. Use consistent heading levels (## for sections, ### for subsections) and bullet lists (- item) for lists. Never include HTML tags in the markdown output.
  66. PROMPT
  67. end
  68. # Returns the expected variables for this prompt type
  69. #
  70. # @return [Hash] Variable definitions
  71. def self.default_variables
  72. {
  73. "url" => { "required" => true, "description" => "The job listing URL" },
  74. "html_content" => { "required" => true, "description" => "The cleaned HTML content" }
  75. }
  76. end
  77. end
  78. end

app/models/ai/job_postprocess_prompt.rb

0.0% lines covered

100.0% branches covered

51 relevant lines. 0 lines covered and 51 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for post-processing job content into structured fields + Markdown.
  4. #
  5. # Variables:
  6. # - {{url}} - The job listing URL
  7. # - {{html_content}} - The job posting content (HTML or text)
  8. class JobPostprocessPrompt < LlmPrompt
  9. def self.default_prompt_template
  10. <<~PROMPT
  11. Given the job posting content below, extract missing structured information and produce a clean Markdown version suitable for display.
  12. IMPORTANT:
  13. - Return ONLY valid JSON (no code fences).
  14. - job_markdown MUST be valid, well-formatted Markdown.
  15. - DO NOT include HTML tags (no <strong>, <p>, etc). Use Markdown instead (**bold**, *italic*).
  16. - Only extract what is present in the content. If something isn't present, return null/[] accordingly.
  17. MARKDOWN FORMAT for job_markdown:
  18. - Use ## for main sections (e.g., ## About the Role, ## Responsibilities, ## Requirements, ## Benefits)
  19. - Use ### for subsections
  20. - Use - for bullet lists (not *)
  21. - Use **text** for bold emphasis
  22. - Use *text* for italic emphasis
  23. - Preserve the logical structure of the original posting
  24. - Include all content: about company, role description, responsibilities, requirements, benefits, etc.
  25. Return JSON with this schema:
  26. {
  27. "job_markdown": String,
  28. "compensation_text": String|null,
  29. "salary_min": Number|null,
  30. "salary_max": Number|null,
  31. "salary_currency": String|null,
  32. "interview_process": String|null,
  33. "responsibilities_bullets": [String],
  34. "requirements_bullets": [String],
  35. "benefits_bullets": [String],
  36. "perks_bullets": [String],
  37. "confidence_score": Number
  38. }
  39. Job URL: {{url}}
  40. Job Content:
  41. {{html_content}}
  42. PROMPT
  43. end
  44. def self.default_system_prompt
  45. <<~PROMPT
  46. You are an expert at extracting structured job posting information and producing clean Markdown for display.
  47. For job_markdown, produce clean, well-formatted Markdown that preserves the job posting's structure. Use consistent heading levels (## for sections, ### for subsections) and bullet lists (- item) for lists. Never include HTML tags in the markdown output.
  48. PROMPT
  49. end
  50. def self.default_variables
  51. {
  52. "url" => { "required" => true, "description" => "The job listing URL" },
  53. "html_content" => { "required" => true, "description" => "The job posting content (HTML or text)" }
  54. }
  55. end
  56. end
  57. end

app/models/ai/llm_api_log.rb

47.06% lines covered

0.0% branches covered

102 relevant lines. 48 lines covered and 54 lines missed.
44 total branches, 0 branches covered and 44 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Ai
  3. # Model for tracking all LLM API calls with full observability
  4. #
  5. # Stores detailed information about every LLM call including
  6. # full request/response payloads, token usage, costs, and performance metrics.
  7. # Supports polymorphic association to any loggable model.
  8. #
  9. # @example
  10. # log = Ai::LlmApiLog.create!(
  11. # operation_type: :job_extraction,
  12. # loggable: job_listing,
  13. # provider: "anthropic",
  14. # model: "claude-sonnet-4-20250514",
  15. # input_tokens: 5000,
  16. # output_tokens: 500,
  17. # latency_ms: 2500,
  18. # status: :success
  19. # )
  20. #
  21. 1 class LlmApiLog < ApplicationRecord
  22. 1 self.table_name = "llm_api_logs"
  23. # Provider cost per 1K tokens (in cents)
  24. # Updated as of 2024 pricing
  25. PROVIDER_COSTS = {
  26. 1 "anthropic" => {
  27. "claude-sonnet-4-20250514" => { input: 0.3, output: 1.5 },
  28. "claude-3-5-sonnet-20241022" => { input: 0.3, output: 1.5 },
  29. "claude-3-haiku-20240307" => { input: 0.025, output: 0.125 }
  30. },
  31. "openai" => {
  32. "gpt-4o" => { input: 0.5, output: 1.5 },
  33. "gpt-4o-mini" => { input: 0.015, output: 0.06 },
  34. "gpt-4-turbo" => { input: 1.0, output: 3.0 }
  35. },
  36. "ollama" => {
  37. # Ollama is free (self-hosted)
  38. "default" => { input: 0.0, output: 0.0 }
  39. }
  40. }.freeze
  41. # Operation types for LLM calls
  42. 1 OPERATION_TYPES = %w[
  43. job_extraction
  44. job_postprocess
  45. email_extraction
  46. resume_extraction
  47. interview_prep_match_analysis
  48. interview_prep_focus_areas
  49. interview_prep_question_framing
  50. interview_prep_strength_positioning
  51. assistant_chat
  52. assistant_tool_call
  53. signal_extraction
  54. email_facts_extraction
  55. interview_round_extraction
  56. round_feedback_extraction
  57. application_status_extraction
  58. round_prep_comprehensive
  59. ].freeze
  60. # Status values
  61. 1 STATUSES = %i[
  62. success
  63. error
  64. timeout
  65. rate_limited
  66. ].freeze
  67. # Associations
  68. 1 belongs_to :loggable, polymorphic: true, optional: true
  69. 1 belongs_to :llm_prompt, class_name: "Ai::LlmPrompt", optional: true
  70. # Enum for status
  71. 1 enum :status, {
  72. success: 0,
  73. error: 1,
  74. timeout: 2,
  75. rate_limited: 3
  76. }, default: :success
  77. # Validations
  78. 1 validates :operation_type, presence: true, inclusion: { in: OPERATION_TYPES }
  79. 1 validates :provider, presence: true
  80. 1 validates :model, presence: true
  81. # Scopes
  82. 1 scope :recent, -> { order(created_at: :desc) }
  83. 1 scope :by_provider, ->(provider) { where(provider: provider) }
  84. 1 scope :by_status, ->(status) { where(status: status) }
  85. 1 scope :by_operation, ->(operation_type) { where(operation_type: operation_type) }
  86. 1 scope :successful, -> { where(status: :success) }
  87. 1 scope :failed, -> { where.not(status: :success) }
  88. 1 scope :with_errors, -> { where(status: [ :error, :timeout, :rate_limited ]) }
  89. 1 scope :recent_period, ->(days = 7) { where("created_at > ?", days.days.ago) }
  90. 1 scope :today, -> { where("created_at > ?", Time.current.beginning_of_day) }
  91. # Scopes for specific operations
  92. 1 scope :job_extractions, -> { by_operation("job_extraction") }
  93. 1 scope :email_extractions, -> { by_operation("email_extraction") }
  94. 1 scope :resume_extractions, -> { by_operation("resume_extraction") }
  95. # Callbacks
  96. 1 before_save :calculate_total_tokens
  97. 1 before_save :calculate_estimated_cost
  98. # Returns formatted cost string
  99. #
  100. # @return [String] Formatted cost (e.g., "$0.0015")
  101. 1 def formatted_cost
  102. then: 0 else: 0 return "N/A" if estimated_cost_cents.nil?
  103. then: 0 else: 0 return "Free" if estimated_cost_cents.zero?
  104. dollars = estimated_cost_cents / 100.0
  105. then: 0 if dollars < 0.01
  106. format("$%.4f", dollars)
  107. else: 0 else
  108. format("$%.2f", dollars)
  109. end
  110. end
  111. # Returns formatted latency string
  112. #
  113. # @return [String] Formatted latency (e.g., "2.5s")
  114. 1 def formatted_latency
  115. then: 0 else: 0 return "N/A" if latency_ms.nil?
  116. then: 0 if latency_ms < 1000
  117. "#{latency_ms}ms"
  118. else: 0 else
  119. "#{(latency_ms / 1000.0).round(2)}s"
  120. end
  121. end
  122. # Returns formatted token usage
  123. #
  124. # @return [String] Formatted tokens (e.g., "5,000 in / 500 out")
  125. 1 def formatted_tokens
  126. parts = []
  127. then: 0 else: 0 parts << "#{number_with_delimiter(input_tokens)} in" if input_tokens
  128. then: 0 else: 0 parts << "#{number_with_delimiter(output_tokens)} out" if output_tokens
  129. then: 0 else: 0 parts.any? ? parts.join(" / ") : "N/A"
  130. end
  131. # Returns the prompt text from request payload
  132. #
  133. # @return [String, nil] The prompt text or nil
  134. 1 def prompt_text
  135. then: 0 else: 0 then: 0 else: 0 request_payload&.dig("prompt") || request_payload&.dig("messages", 0, "content")
  136. end
  137. # Returns the response text from response payload
  138. #
  139. # @return [String, nil] The response text or nil
  140. 1 def response_text
  141. then: 0 else: 0 then: 0 else: 0 response_payload&.dig("content") || response_payload&.dig("text")
  142. end
  143. # Returns the list of successfully extracted field names
  144. #
  145. # @return [Array<String>] Field names
  146. 1 def extracted_field_names
  147. Array(extracted_fields).map(&:to_s)
  148. end
  149. # Returns status badge color for UI
  150. #
  151. # @return [String] Color name
  152. 1 def status_badge_color
  153. when: 0 case status.to_sym
  154. when: 0 when :success then "success"
  155. when: 0 when :error then "danger"
  156. when: 0 when :timeout then "warning"
  157. else: 0 when :rate_limited then "info"
  158. else "neutral"
  159. end
  160. end
  161. # Returns operation type badge color for UI
  162. #
  163. # @return [String] Color name
  164. 1 def operation_badge_color
  165. when: 0 case operation_type
  166. when: 0 when "job_extraction" then "blue"
  167. when: 0 when "job_postprocess" then "blue"
  168. when: 0 when "email_extraction" then "purple"
  169. else: 0 when "resume_extraction" then "green"
  170. else "gray"
  171. end
  172. end
  173. # Returns human-readable operation type
  174. #
  175. # @return [String] Operation name
  176. 1 def operation_type_name
  177. operation_type.humanize.titleize
  178. end
  179. # Class methods for aggregations
  180. 1 class << self
  181. # Calculates total cost for a period
  182. #
  183. # @param days [Integer] Number of days
  184. # @return [Float] Total cost in dollars
  185. 1 def total_cost_for_period(days = 7)
  186. recent_period(days).sum(:estimated_cost_cents).to_f / 100.0
  187. end
  188. # Calculates average latency for a period
  189. #
  190. # @param days [Integer] Number of days
  191. # @return [Float] Average latency in ms
  192. 1 def average_latency_for_period(days = 7)
  193. recent_period(days).where.not(latency_ms: nil).average(:latency_ms).to_f.round(0)
  194. end
  195. # Calculates success rate for a period
  196. #
  197. # @param days [Integer] Number of days
  198. # @return [Float] Success rate percentage
  199. 1 def success_rate_for_period(days = 7)
  200. logs = recent_period(days)
  201. then: 0 else: 0 return 0.0 if logs.count.zero?
  202. (logs.successful.count.to_f / logs.count * 100).round(1)
  203. end
  204. # Returns token usage breakdown by provider
  205. #
  206. # @param days [Integer] Number of days
  207. # @return [Array<Hash>] Usage by provider
  208. 1 def token_usage_by_provider(days = 7)
  209. recent_period(days)
  210. .group(:provider)
  211. .select("provider, SUM(input_tokens) as total_input, SUM(output_tokens) as total_output, SUM(total_tokens) as total")
  212. .map do |result|
  213. {
  214. provider: result.provider,
  215. input_tokens: result.total_input.to_i,
  216. output_tokens: result.total_output.to_i,
  217. total_tokens: result.total.to_i
  218. }
  219. end
  220. end
  221. # Returns cost breakdown by provider
  222. #
  223. # @param days [Integer] Number of days
  224. # @return [Hash] Cost by provider
  225. 1 def cost_by_provider(days = 7)
  226. recent_period(days)
  227. .group(:provider)
  228. .sum(:estimated_cost_cents)
  229. .transform_values { |v| (v.to_f / 100.0).round(4) }
  230. end
  231. # Returns cost breakdown by operation type
  232. #
  233. # @param days [Integer] Number of days
  234. # @return [Hash] Cost by operation
  235. 1 def cost_by_operation(days = 7)
  236. recent_period(days)
  237. .group(:operation_type)
  238. .sum(:estimated_cost_cents)
  239. .transform_values { |v| (v.to_f / 100.0).round(4) }
  240. end
  241. # Returns error breakdown by type
  242. #
  243. # @param days [Integer] Number of days
  244. # @return [Hash] Error counts by type
  245. 1 def error_breakdown(days = 7)
  246. recent_period(days)
  247. .with_errors
  248. .group(:status, :error_type)
  249. .count
  250. end
  251. # Returns counts by operation type
  252. #
  253. # @param days [Integer] Number of days
  254. # @return [Hash] Counts by operation
  255. 1 def counts_by_operation(days = 7)
  256. recent_period(days)
  257. .group(:operation_type)
  258. .count
  259. end
  260. end
  261. 1 private
  262. # Calculates total tokens from input and output
  263. 1 def calculate_total_tokens
  264. self.total_tokens = (input_tokens || 0) + (output_tokens || 0)
  265. end
  266. # Calculates estimated cost based on provider pricing
  267. 1 def calculate_estimated_cost
  268. then: 0 else: 0 return if provider.blank? || model.blank?
  269. provider_pricing = PROVIDER_COSTS.dig(provider.downcase)
  270. else: 0 then: 0 return unless provider_pricing
  271. # Try exact model match first, then default
  272. model_pricing = provider_pricing[model] || provider_pricing["default"]
  273. else: 0 then: 0 return unless model_pricing
  274. input_cost = ((input_tokens || 0) / 1000.0) * model_pricing[:input]
  275. output_cost = ((output_tokens || 0) / 1000.0) * model_pricing[:output]
  276. self.estimated_cost_cents = ((input_cost + output_cost) * 100).round
  277. end
  278. # Helper for number formatting
  279. 1 def number_with_delimiter(number)
  280. then: 0 else: 0 return "0" if number.nil?
  281. number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
  282. end
  283. end
  284. end

app/models/ai/llm_prompt.rb

0.0% lines covered

100.0% branches covered

54 relevant lines. 0 lines covered and 54 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Base class for LLM prompts using STI (Single Table Inheritance)
  4. #
  5. # Provides common functionality for all prompt types with support for:
  6. # - Version management
  7. # - Active/inactive status with only one active per type
  8. # - Variable substitution in templates
  9. #
  10. # @example
  11. # prompt = Ai::JobExtractionPrompt.active_prompt
  12. # final_prompt = prompt.build_prompt(url: "https://...", html_content: "...")
  13. #
  14. class LlmPrompt < ApplicationRecord
  15. self.table_name = "llm_prompts"
  16. # Associations
  17. has_many :llm_api_logs, class_name: "Ai::LlmApiLog", dependent: :nullify
  18. # Validations
  19. validates :name, presence: true
  20. validates :prompt_template, presence: true
  21. validates :version, numericality: { only_integer: true, greater_than: 0 }
  22. validates :type, presence: true
  23. # Scopes
  24. scope :active_prompts, -> { where(active: true) }
  25. scope :inactive_prompts, -> { where(active: false) }
  26. scope :by_version_desc, -> { order(version: :desc) }
  27. scope :by_name, -> { order(:name) }
  28. # Callbacks
  29. before_save :deactivate_others_of_same_type, if: -> { active? && active_changed? }
  30. # Returns the currently active prompt for this type
  31. #
  32. # @return [Ai::LlmPrompt, nil] Active prompt or nil
  33. def self.active_prompt
  34. active_prompts.by_version_desc.first
  35. end
  36. # Returns the default prompt template for this type
  37. # Subclasses should override this method
  38. #
  39. # @return [String] Default prompt template
  40. def self.default_prompt_template
  41. raise NotImplementedError, "Subclasses must implement default_prompt_template"
  42. end
  43. # Returns the default prompt, either from DB or fallback
  44. #
  45. # @return [String] Prompt template
  46. def self.default_prompt
  47. active_prompt&.prompt_template || default_prompt_template
  48. end
  49. # Builds a prompt with variables substituted
  50. #
  51. # @param variables [Hash] Variables to substitute (e.g., url:, html_content:)
  52. # @return [String] Final prompt with variables replaced
  53. def build_prompt(variables = {})
  54. template = prompt_template.dup
  55. variables.each do |key, value|
  56. template.gsub!("{{#{key}}}", value.to_s)
  57. end
  58. template
  59. end
  60. # Returns placeholder variables used in template
  61. #
  62. # @return [Array<String>] Variable names found in template
  63. def template_variables
  64. prompt_template.scan(/\{\{(\w+)\}\}/).flatten.uniq
  65. end
  66. # Checks if all required variables are defined
  67. #
  68. # @return [Boolean] True if all variables are defined
  69. def variables_complete?
  70. return true if variables.blank?
  71. required_vars = variables.select { |_, v| v["required"] == true }.keys
  72. template_vars = template_variables
  73. required_vars.all? { |v| template_vars.include?(v) }
  74. end
  75. # Returns human-readable prompt type name
  76. #
  77. # @return [String] Type name
  78. def prompt_type_name
  79. self.class.name.demodulize.underscore.humanize.titleize
  80. end
  81. # Duplicates the prompt with incremented version
  82. #
  83. # @return [Ai::LlmPrompt] New prompt instance (not saved)
  84. def duplicate
  85. dup.tap do |new_prompt|
  86. new_prompt.name = "#{name} (Copy)"
  87. new_prompt.active = false
  88. new_prompt.version = version + 1
  89. end
  90. end
  91. private
  92. # Deactivates all other prompts of the same STI type
  93. def deactivate_others_of_same_type
  94. self.class.where.not(id: id).update_all(active: false)
  95. end
  96. end
  97. end

app/models/ai/resume_skill_extraction_prompt.rb

0.0% lines covered

100.0% branches covered

94 relevant lines. 0 lines covered and 94 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for extracting skills from resume/CV text
  4. #
  5. # Used by Resumes::AiSkillExtractorService to extract structured skill data
  6. # from parsed resume text.
  7. #
  8. # Variables:
  9. # - {{resume_text}} - The parsed resume text content
  10. #
  11. class ResumeSkillExtractionPrompt < LlmPrompt
  12. # Default prompt template for resume skill extraction
  13. #
  14. # @return [String] Default prompt template
  15. def self.default_prompt_template
  16. <<~PROMPT
  17. Analyze the following resume/Curriculum Vitae text and extract all professional skills, competencies, work history, and areas of expertise.
  18. For each skill identified, provide:
  19. 1. **name**: The skill name (use common industry terminology, e.g., "Ruby on Rails" not "RoR")
  20. 2. **category**: One of: Backend, Frontend, Fullstack, Infrastructure, DevOps, Data, Mobile, Leadership, Communication, ProjectManagement, Design, Security, AI/ML, Other
  21. 3. **proficiency**: A level from 1-5 based on evidence in the resume:
  22. - 5 = Expert (extensive experience, leadership, teaching others)
  23. - 4 = Advanced (significant professional experience, complex projects)
  24. - 3 = Intermediate (solid working knowledge, multiple projects)
  25. - 2 = Elementary (some experience, basic projects)
  26. - 1 = Beginner (mentioned but limited evidence)
  27. 4. **confidence**: Your confidence in this assessment (0.0-1.0)
  28. 5. **evidence**: A brief quote or description from the resume supporting this skill
  29. 6. **years**: Estimated years of experience if determinable (null if unclear)
  30. Also extract work history:
  31. - For each job/position, provide: **company** (full company name), **company_domain** (the industry/domain the company operates in, e.g., "FinTech", "SaaS", "Healthcare", "E-commerce", "EdTech", "AI/ML", "Cybersecurity", "Enterprise Software", "B2B", "B2C", etc. - use null if unclear), **role** (job title), **role_department** (the department/function this role belongs to, one of: "Engineering", "Product", "Design", "Data Science", "DevOps/SRE", "Sales", "Marketing", "Customer Success", "Finance", "HR/People", "Legal", "Operations", "Executive", "Research", "QA/Testing", "Security", "IT", "Content", "Other"), **duration** (years or months), **highlight** (a brief description of the job's most significant accomplishment), **start_date** (the date the job started), **end_date** (the date the job ended or null if still current), **current** (true if the job is still current, false if it has ended), **responsibilities** (an array of responsibilities the candidate had in the job), **skills_used** (an array of skills the candidate used in the job)
  32. - For each skill used, provide: **name** (the skill name), **confidence** (your confidence in this assessment of the skill's usage, 0.0-1.0), **evidence** (a brief quote or description from the resume supporting this skill usage)
  33. - Order from most recent to oldest
  34. Also provide:
  35. - A brief **summary** of the candidate's overall profile (2-3 sentences)
  36. - An **overall_confidence** score for the entire extraction (0.0-1.0)
  37. - **strengths**: Top 3-5 key strengths based on the resume
  38. - **domains**: Primary professional domains/industries
  39. - **resume_date**: Estimated date when this resume was last updated (YYYY-MM-DD format, or null if unknown). Look for document dates, "updated" mentions, or infer from the most recent job end date.
  40. - **resume_date_confidence**: How confident you are in the resume date ("high", "medium", "low", or "unknown")
  41. - **resume_date_source**: How you determined the date ("document_metadata", "explicit_mention", "most_recent_job", or "unknown")
  42. Respond with valid JSON only, no markdown or explanation:
  43. {
  44. "skills": [
  45. {
  46. "name": "Ruby on Rails",
  47. "category": "Backend",
  48. "proficiency": 4,
  49. "confidence": 0.9,
  50. "evidence": "5+ years building Rails applications at scale",
  51. "years": 5
  52. }
  53. ],
  54. "work_history": [
  55. {
  56. "company": "Acme Corp",
  57. "company_domain": "FinTech",
  58. "role": "Senior Software Engineer",
  59. "role_department": "Engineering",
  60. "duration": "3 years",
  61. "highlight": "Built scalable backend infrastructure for a fintech startup",
  62. "start_date": "2021-01-01",
  63. "end_date": "2024-01-01",
  64. "current": false,
  65. "responsibilities": [
  66. "Developed and maintained scalable backend infrastructure",
  67. "Built RESTful APIs for internal tools and external integrations",
  68. "Optimized database queries and implemented caching strategies",
  69. "Collaborated with frontend and mobile teams to ensure seamless integration"
  70. ],
  71. "skills_used": [
  72. {
  73. "name": "Ruby on Rails",
  74. "confidence": 0.9,
  75. "evidence": "5+ years building Rails applications at scale"
  76. }
  77. ]
  78. }
  79. ],
  80. "summary": "Senior backend engineer with strong Ruby and distributed systems experience...",
  81. "overall_confidence": 0.85,
  82. "strengths": ["Backend Development", "System Design", "Team Leadership"],
  83. "domains": ["FinTech", "SaaS"],
  84. "resume_date": "2024-06-15",
  85. "resume_date_confidence": "medium",
  86. "resume_date_source": "most_recent_job"
  87. }
  88. RESUME TEXT:
  89. {{resume_text}}
  90. PROMPT
  91. end
  92. def self.default_system_prompt
  93. <<~PROMPT
  94. You are an expert at extracting skills, strengths, domains, competencies, work history, and areas of expertise from unstructured text extracted from a resume or Curriculum Vitae, then converting it into structured json response.
  95. Your goal is to return only valid JSON. Do not guess missing values; use null.
  96. Do not include markdown or extra commentary.
  97. Do not include any other text or formatting.
  98. PROMPT
  99. end
  100. # Returns the expected variables for this prompt type
  101. #
  102. # @return [Hash] Variable definitions
  103. def self.default_variables
  104. {
  105. "resume_text" => { "required" => true, "description" => "The parsed resume text content" }
  106. }
  107. end
  108. end
  109. end

app/models/ai/round_feedback_extraction_prompt.rb

0.0% lines covered

100.0% branches covered

85 relevant lines. 0 lines covered and 85 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for extracting interview round feedback from emails
  4. #
  5. # Used by Signals::RoundFeedbackProcessor to extract pass/fail results
  6. # and detailed feedback from per-round feedback emails.
  7. #
  8. # Variables:
  9. # - {{subject}} - The email subject line
  10. # - {{body}} - The email body content
  11. # - {{from_email}} - The sender's email address
  12. # - {{from_name}} - The sender's display name
  13. # - {{company_name}} - The company name if known
  14. # - {{recent_rounds}} - JSON array of recent interview rounds for context
  15. #
  16. class RoundFeedbackExtractionPrompt < LlmPrompt
  17. # Default prompt template for round feedback extraction
  18. #
  19. # @return [String] Default prompt template
  20. def self.default_prompt_template
  21. <<~PROMPT
  22. Analyze the following email to extract interview round feedback/results.
  23. FROM: {{from_name}} <{{from_email}}>
  24. SUBJECT: {{subject}}
  25. COMPANY: {{company_name}}
  26. RECENT INTERVIEW ROUNDS (for context):
  27. {{recent_rounds}}
  28. EMAIL CONTENT:
  29. {{body}}
  30. Extract the following information and respond with a JSON object:
  31. {
  32. "result": "passed|failed|waitlisted|unknown",
  33. "round_context": {
  34. "stage_mentioned": "The stage/round name mentioned (e.g., 'technical round', 'phone screen', 'final interview')",
  35. "interviewer_mentioned": "Name of interviewer mentioned in feedback",
  36. "date_mentioned": "Date of the interview being discussed, if mentioned"
  37. },
  38. "feedback": {
  39. "has_detailed_feedback": true/false,
  40. "summary": "Brief summary of the feedback",
  41. "strengths": ["Array of things that went well"],
  42. "improvements": ["Array of areas to improve"],
  43. "full_feedback_text": "Complete feedback text if provided"
  44. },
  45. "next_steps": {
  46. "has_next_round": true/false,
  47. "next_round_type": "Type of next round (e.g., 'technical', 'hiring manager', 'onsite')",
  48. "next_round_hint": "Any hints about what the next round involves",
  49. "timeline_hint": "Any mention of timeline (e.g., 'next week', 'within a few days')"
  50. },
  51. "is_final_round_result": true/false,
  52. "sentiment": "positive|negative|neutral",
  53. "confidence_score": 0.0 to 1.0
  54. }
  55. Guidelines for result detection:
  56. - "passed" - Clear indication of moving forward: "congratulations", "pleased to inform", "moving to next round", "passed"
  57. - "failed" - Clear rejection for this round: "not moving forward", "decided not to proceed", "unfortunately"
  58. - "waitlisted" - On hold: "waitlist", "keep you in mind", "position on hold"
  59. - "unknown" - Cannot determine outcome from email content
  60. Guidelines for round matching:
  61. - Look for stage mentions like "technical interview", "phone screen", "hiring manager round"
  62. - Look for interviewer names mentioned in feedback
  63. - Look for date references to match with recent rounds
  64. Guidelines for feedback extraction:
  65. - Capture specific strengths mentioned (technical skills, communication, etc.)
  66. - Capture specific areas for improvement
  67. - If detailed feedback is provided, include the full text
  68. Guidelines for next steps:
  69. - Detect if there's a next round scheduled or mentioned
  70. - Identify what type of round comes next
  71. - Note any timeline information
  72. Use null for any field where information is not clearly available.
  73. Respond ONLY with the JSON object, no additional text or markdown.
  74. PROMPT
  75. end
  76. # Default system prompt for round feedback extraction
  77. #
  78. # @return [String]
  79. def self.default_system_prompt
  80. <<~PROMPT
  81. You are an expert at analyzing interview feedback emails.
  82. Your goal is to:
  83. - Determine if the candidate passed, failed, or is waitlisted for this round
  84. - Extract any specific feedback provided
  85. - Identify what the next steps are
  86. - Match the feedback to a specific interview round if possible
  87. Rules:
  88. - Return ONLY valid JSON, no markdown or commentary
  89. - Use null for missing information, never guess
  90. - Be careful to distinguish between per-round rejection and full application rejection
  91. - "Passed" means moving to next round, not necessarily getting the job
  92. PROMPT
  93. end
  94. # Returns the expected variables for this prompt type
  95. #
  96. # @return [Hash] Variable definitions
  97. def self.default_variables
  98. {
  99. "subject" => { "required" => true, "description" => "The email subject line" },
  100. "body" => { "required" => true, "description" => "The email body content" },
  101. "from_email" => { "required" => true, "description" => "The sender's email address" },
  102. "from_name" => { "required" => false, "description" => "The sender's display name" },
  103. "company_name" => { "required" => false, "description" => "The company name if known" },
  104. "recent_rounds" => { "required" => false, "description" => "JSON array of recent interview rounds" }
  105. }
  106. end
  107. end
  108. end

app/models/ai/round_prep_prompt.rb

0.0% lines covered

100.0% branches covered

114 relevant lines. 0 lines covered and 114 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for generating round-specific interview preparation
  4. #
  5. # Used by InterviewRoundPrep::GenerateService to generate tailored prep content
  6. # for specific interview rounds based on round type, company patterns, and user history.
  7. #
  8. # Variables:
  9. # - {{round_context}} - JSON with round details (type, stage, duration, interviewer)
  10. # - {{job_context}} - JSON with job/company information
  11. # - {{candidate_profile}} - JSON with candidate background and skills
  12. # - {{historical_performance}} - JSON with user's performance on similar rounds
  13. # - {{company_patterns}} - JSON with company-specific interview patterns
  14. #
  15. class RoundPrepPrompt < LlmPrompt
  16. # Default prompt template for round prep generation
  17. #
  18. # @return [String] Default prompt template
  19. def self.default_prompt_template
  20. <<~PROMPT
  21. Generate focused interview preparation for a specific interview round.
  22. INTERVIEW ROUND:
  23. {{round_context}}
  24. JOB CONTEXT:
  25. {{job_context}}
  26. CANDIDATE PROFILE:
  27. {{candidate_profile}}
  28. CANDIDATE'S HISTORICAL PERFORMANCE ON SIMILAR ROUNDS:
  29. {{historical_performance}}
  30. COMPANY INTERVIEW PATTERNS:
  31. {{company_patterns}}
  32. Generate a comprehensive prep guide as a JSON object with this structure:
  33. {
  34. "round_summary": {
  35. "type": "The round type slug (e.g., 'coding', 'system_design', 'behavioral')",
  36. "type_name": "Human-readable round type name",
  37. "company": "Company name",
  38. "typical_duration": "Expected duration (e.g., '45-60 min')",
  39. "format_hints": ["Array of format hints based on round type and company patterns"]
  40. },
  41. "expected_questions": [
  42. {
  43. "category": "Question category or theme",
  44. "example": "Example question or topic",
  45. "your_preparation": "Personalized prep advice based on candidate's background",
  46. "difficulty": "easy/medium/hard"
  47. }
  48. ],
  49. "your_history": {
  50. "same_type_rounds": "Number of similar rounds completed",
  51. "pass_rate": "Pass rate percentage or null",
  52. "strengths": ["Identified strengths from historical performance"],
  53. "areas_to_watch": ["Areas that need attention based on past feedback"]
  54. },
  55. "company_patterns": {
  56. "typical_focus": ["Areas this company typically focuses on"],
  57. "interview_style": "Description of interview style",
  58. "success_factors": ["Factors that correlate with success at this company"]
  59. },
  60. "answer_strategies": [
  61. {
  62. "strategy": "Strategy name",
  63. "description": "How to apply this strategy",
  64. "example_application": "Concrete example for this interview"
  65. }
  66. ],
  67. "preparation_checklist": [
  68. "Specific, actionable preparation items for this round"
  69. ],
  70. "tips": [
  71. "Quick tips specific to this round type and company"
  72. ]
  73. }
  74. Guidelines:
  75. - Tailor everything to the specific round type and candidate's background
  76. - Reference the candidate's actual skills and experience where relevant
  77. - Use historical performance data to personalize strengths and areas to watch
  78. - Incorporate company-specific patterns into format hints and focus areas
  79. - Keep preparation checklist items specific and actionable
  80. - Provide 3-5 expected questions with personalized prep advice
  81. - Provide 2-3 answer strategies relevant to this round type
  82. - Keep tips concise and immediately actionable (5-7 tips max)
  83. - If historical data is limited, focus on general best practices for the round type
  84. Respond ONLY with the JSON object, no additional text or markdown.
  85. PROMPT
  86. end
  87. # Default system prompt for round prep generation
  88. #
  89. # @return [String]
  90. def self.default_system_prompt
  91. <<~PROMPT
  92. You are an expert interview coach helping candidates prepare for specific interview rounds.
  93. Your goal is to provide personalized, actionable preparation guidance that:
  94. - Leverages the candidate's specific background and experience
  95. - Addresses their known strengths and areas for improvement
  96. - Incorporates patterns specific to the target company
  97. - Provides concrete, actionable preparation steps
  98. - Is tailored to the specific round type (coding, system design, behavioral, etc.)
  99. Rules:
  100. - Return ONLY valid JSON, no markdown or commentary
  101. - Be specific and personalized - avoid generic advice
  102. - Focus on what the candidate can do in the time before the interview
  103. - Reference actual skills and experience from the candidate profile
  104. - If company data is limited, extrapolate from general industry patterns
  105. - Keep advice practical and confidence-building
  106. PROMPT
  107. end
  108. # Returns the expected variables for this prompt type
  109. #
  110. # @return [Hash] Variable definitions
  111. def self.default_variables
  112. {
  113. "round_context" => {
  114. "required" => true,
  115. "description" => "JSON with interview round details (type, stage, duration, interviewer)"
  116. },
  117. "job_context" => {
  118. "required" => true,
  119. "description" => "JSON with job and company information"
  120. },
  121. "candidate_profile" => {
  122. "required" => true,
  123. "description" => "JSON with candidate background, skills, and experience"
  124. },
  125. "historical_performance" => {
  126. "required" => false,
  127. "description" => "JSON with candidate's historical performance on similar rounds"
  128. },
  129. "company_patterns" => {
  130. "required" => false,
  131. "description" => "JSON with company-specific interview patterns"
  132. }
  133. }
  134. end
  135. end
  136. end

app/models/ai/signal_extraction_prompt.rb

0.0% lines covered

100.0% branches covered

98 relevant lines. 0 lines covered and 98 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for extracting actionable signals from interview-related emails
  4. #
  5. # Used by Signals::ExtractionService to extract structured intelligence
  6. # from email content including company info, recruiter details, job information,
  7. # relevant links, and suggested actions.
  8. #
  9. # Variables:
  10. # - {{subject}} - The email subject line
  11. # - {{body}} - The email body content
  12. # - {{from_email}} - The sender's email address
  13. # - {{from_name}} - The sender's display name
  14. # - {{email_type}} - The classified email type (interview_invite, recruiter_outreach, etc.)
  15. #
  16. class SignalExtractionPrompt < LlmPrompt
  17. # Default prompt template for signal extraction
  18. #
  19. # @return [String] Default prompt template
  20. def self.default_prompt_template
  21. <<~PROMPT
  22. Analyze the following interview-related email and extract actionable intelligence.
  23. The email has been classified as: {{email_type}}
  24. FROM: {{from_name}} <{{from_email}}>
  25. SUBJECT: {{subject}}
  26. EMAIL CONTENT:
  27. {{body}}
  28. Extract the following information and respond with a JSON object:
  29. {
  30. "company": {
  31. "name": "The company with the job opening (extract from signature, domain, or content)",
  32. "website": "Company website URL if mentioned or derivable from email domain",
  33. "careers_url": "URL to careers/jobs page if found",
  34. "domain": "Industry domain (e.g., 'FinTech', 'SaaS', 'Healthcare', 'E-commerce', 'AI/ML', etc.)"
  35. },
  36. "recruiter": {
  37. "name": "Recruiter/sender's full name",
  38. "email": "Recruiter's email address (may differ from sender if forwarded)",
  39. "title": "Recruiter's job title (e.g., 'Senior Recruiter', 'Talent Acquisition Manager')",
  40. "linkedin_url": "Recruiter's LinkedIn profile URL if found"
  41. },
  42. "job": {
  43. "title": "Job title or role being discussed",
  44. "department": "Department (Engineering, Product, Design, Data Science, etc.)",
  45. "location": "Job location (city, remote, hybrid, etc.)",
  46. "url": "Direct URL to the job posting or application page",
  47. "salary_hint": "Any mention of compensation, salary range, or benefits"
  48. },
  49. "action_links": [
  50. {
  51. "url": "Full URL found in the email",
  52. "action_label": "Human-readable action button text (e.g., 'Schedule Interview', 'View Job at Toptal', 'Apply Now', 'Learn About Our Culture')",
  53. "priority": 1-5 (1=most important action, 5=least important)
  54. }
  55. ],
  56. "suggested_actions": [
  57. "Array of backend actions (usually empty - UI handles most actions automatically)",
  58. "Only include: start_application (if this is clearly a NEW opportunity worth tracking as an application)"
  59. ],
  60. "key_insights": "Brief summary of important details (tech stack, team size, interview process, timeline, etc.)",
  61. "is_forwarded": true/false,
  62. "confidence_score": 0.0 to 1.0
  63. }
  64. Guidelines for action_links:
  65. - Include only the MOST RELEVANT links
  66. - Prefer direct, first-party links (avoid redirect wrappers)
  67. - Generate ACTIONABLE button labels that tell the user what clicking will do
  68. - Include company name in labels when relevant (e.g., "View Toptal Careers" not just "View Careers")
  69. - For scheduling links (calendly, goodtime, etc.), use labels like "Schedule Interview" or "Book Call"
  70. - For interview joins, use "Join [Company] Interview" or "Join Zoom Interview"
  71. - For job postings, use "View Job Posting" or "Apply for [Role]"
  72. - For company pages, use "Learn About [Company]" or "Visit [Company] Website"
  73. - Exclude low-value or boilerplate links (unsubscribe, view in browser, privacy, terms, help, forwarding guides, calendar event details, generic Google Calendar links)
  74. - Prioritize: 1=scheduling/join/apply, 2=job posting, 3=company info, 4=recruiter profile
  75. - Only include links that provide value to the job seeker
  76. Guidelines for suggested_actions:
  77. - start_application: Include ONLY if this is clearly a new opportunity (e.g., recruiter outreach, interview invite)
  78. - Most emails don't need suggested_actions - the UI provides matching functionality
  79. - Company and recruiter info is saved automatically, no action needed
  80. Other guidelines:
  81. - Extract company name from email signature, domain, or content
  82. - If sender email domain is a company domain (not gmail/outlook/etc.), use it to derive company website
  83. - Use null for any field where information is not clearly available
  84. - confidence_score should reflect overall extraction quality (0.0-1.0)
  85. Respond ONLY with the JSON object, no additional text or markdown.
  86. PROMPT
  87. end
  88. # Default system prompt for signal extraction
  89. #
  90. # @return [String]
  91. def self.default_system_prompt
  92. <<~PROMPT
  93. You are an expert at extracting actionable intelligence from interview and recruiting emails.
  94. Your goal is to identify key information that helps job seekers take action:
  95. - Company details for research
  96. - Recruiter contact info for follow-up
  97. - Job details for application tracking
  98. - Scheduling links for booking interviews
  99. - Relevant actions the user should take
  100. Rules:
  101. - Return ONLY valid JSON, no markdown or commentary
  102. - Use null for missing information, never guess
  103. - Extract URLs exactly as they appear, but avoid redirect wrappers when possible
  104. - Avoid returning long, exhaustive link lists
  105. - Be conservative with confidence scores
  106. PROMPT
  107. end
  108. # Returns the expected variables for this prompt type
  109. #
  110. # @return [Hash] Variable definitions
  111. def self.default_variables
  112. {
  113. "subject" => { "required" => true, "description" => "The email subject line" },
  114. "body" => { "required" => true, "description" => "The email body content" },
  115. "from_email" => { "required" => true, "description" => "The sender's email address" },
  116. "from_name" => { "required" => false, "description" => "The sender's display name" },
  117. "email_type" => { "required" => false, "description" => "The classified email type" }
  118. }
  119. end
  120. end
  121. end

app/models/ai/status_extraction_prompt.rb

0.0% lines covered

100.0% branches covered

94 relevant lines. 0 lines covered and 94 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Prompt template for extracting application status changes from emails
  4. #
  5. # Used by Signals::ApplicationStatusProcessor to extract rejection, offer,
  6. # and status update information from emails.
  7. #
  8. # Variables:
  9. # - {{subject}} - The email subject line
  10. # - {{body}} - The email body content
  11. # - {{from_email}} - The sender's email address
  12. # - {{from_name}} - The sender's display name
  13. # - {{company_name}} - The company name if known
  14. # - {{current_status}} - The current application status
  15. #
  16. class StatusExtractionPrompt < LlmPrompt
  17. # Default prompt template for status extraction
  18. #
  19. # @return [String] Default prompt template
  20. def self.default_prompt_template
  21. <<~PROMPT
  22. Analyze the following email to determine if it indicates a change in application status.
  23. FROM: {{from_name}} <{{from_email}}>
  24. SUBJECT: {{subject}}
  25. COMPANY: {{company_name}}
  26. CURRENT APPLICATION STATUS: {{current_status}}
  27. EMAIL CONTENT:
  28. {{body}}
  29. Extract the following information and respond with a JSON object:
  30. {
  31. "status_change": {
  32. "type": "rejection|offer|withdrawal|ghosted|on_hold|no_change",
  33. "is_final": true/false,
  34. "effective_date": "ISO 8601 date if mentioned"
  35. },
  36. "rejection_details": {
  37. "reason": "The stated reason for rejection (position filled, other candidates, not a fit, etc.)",
  38. "stage_rejected_at": "Stage where rejection occurred if mentioned (screening, technical, final, etc.)",
  39. "is_generic": true/false (true if it's a generic rejection template),
  40. "door_open": true/false (true if they mention keeping in touch for future opportunities)
  41. },
  42. "offer_details": {
  43. "role_title": "Job title offered",
  44. "department": "Department/team",
  45. "start_date": "Proposed start date if mentioned",
  46. "response_deadline": "Deadline to respond to offer",
  47. "includes_compensation_info": true/false,
  48. "compensation_hints": "Any salary/benefits mentioned (do not extract exact numbers)",
  49. "next_steps": "What they need from the candidate (sign offer, complete background check, etc.)"
  50. },
  51. "feedback": {
  52. "has_feedback": true/false,
  53. "feedback_text": "Any feedback provided about the candidate",
  54. "is_constructive": true/false (true if actionable feedback is given)
  55. },
  56. "follow_up": {
  57. "should_follow_up": true/false,
  58. "follow_up_date": "Suggested follow-up date if mentioned",
  59. "contact_person": "Who to contact for questions",
  60. "contact_email": "Email to contact"
  61. },
  62. "sentiment": "positive|negative|neutral|mixed",
  63. "confidence_score": 0.0 to 1.0
  64. }
  65. Guidelines for status_change.type:
  66. - "rejection" - Clear indication the application/interview process is ending negatively
  67. - "offer" - Explicit job offer being extended
  68. - "withdrawal" - Company withdrawing the position/process
  69. - "ghosted" - This email indicates extended silence or ghosting
  70. - "on_hold" - Position/process is paused but not ended
  71. - "no_change" - Email doesn't indicate a status change (follow-up, scheduling, etc.)
  72. Guidelines for rejection detection:
  73. - Look for: "regret to inform", "not moving forward", "decided to go with other candidates"
  74. - Check if it's a per-round rejection (fail one round) vs full application rejection
  75. - is_generic = true for templated rejections with no personalization
  76. - door_open = true if they mention "keep your resume on file" or "future opportunities"
  77. Guidelines for offer detection:
  78. - Must be an actual job offer, not just positive feedback
  79. - Look for: "pleased to offer", "extend an offer", "offer letter", "congratulations"
  80. - Note any deadlines for responding to the offer
  81. Use null for any field where information is not clearly available.
  82. Respond ONLY with the JSON object, no additional text or markdown.
  83. PROMPT
  84. end
  85. # Default system prompt for status extraction
  86. #
  87. # @return [String]
  88. def self.default_system_prompt
  89. <<~PROMPT
  90. You are an expert at analyzing job application emails to detect status changes.
  91. Your goal is to:
  92. - Determine if the email indicates a rejection, offer, or other status change
  93. - Extract relevant details about the change
  94. - Identify any feedback or next steps mentioned
  95. - Distinguish between per-round rejection and full application rejection
  96. Rules:
  97. - Return ONLY valid JSON, no markdown or commentary
  98. - Use null for missing information, never guess
  99. - Be conservative - only mark as rejection/offer if clearly indicated
  100. - "Congratulations on moving to the next round" is NOT an offer
  101. PROMPT
  102. end
  103. # Returns the expected variables for this prompt type
  104. #
  105. # @return [Hash] Variable definitions
  106. def self.default_variables
  107. {
  108. "subject" => { "required" => true, "description" => "The email subject line" },
  109. "body" => { "required" => true, "description" => "The email body content" },
  110. "from_email" => { "required" => true, "description" => "The sender's email address" },
  111. "from_name" => { "required" => false, "description" => "The sender's display name" },
  112. "company_name" => { "required" => false, "description" => "The company name if known" },
  113. "current_status" => { "required" => false, "description" => "The current application status" }
  114. }
  115. end
  116. end
  117. end

app/models/application_record.rb

100.0% lines covered

100.0% branches covered

2 relevant lines. 2 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 class ApplicationRecord < ActiveRecord::Base
  2. 1 primary_abstract_class
  3. end

app/models/application_skill_tag.rb

0.0% lines covered

100.0% branches covered

7 relevant lines. 0 lines covered and 7 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # ApplicationSkillTag join model connecting interview applications with skill tags
  3. class ApplicationSkillTag < ApplicationRecord
  4. self.table_name = "interview_skill_tags"
  5. belongs_to :interview_application, foreign_key: :interview_id
  6. belongs_to :skill_tag
  7. validates :interview_application, :skill_tag, presence: true
  8. validates :interview_id, uniqueness: { scope: :skill_tag_id }
  9. end

app/models/base_aasm.rb

88.89% lines covered

100.0% branches covered

9 relevant lines. 8 lines covered and 1 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 class BaseAasm < AASM::Base
  2. 1 def log_transitions!
  3. 3 klass.class_eval do
  4. 3 aasm with_klass: BaseAasm do
  5. 3 after_all_transitions :log_transitions
  6. end
  7. end
  8. end
  9. # A custom annotation that we want available across many AASM models.
  10. 1 def requires_guards!
  11. 3 klass.class_eval do
  12. 3 def log_transitions
  13. Transition.create!(event: aasm.current_event, from_state: aasm.from_state, to_state: aasm.to_state, resource: self)
  14. end
  15. end
  16. end
  17. end

app/models/billing/customer.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Stores the mapping between an internal user and a payment provider's customer record.
  4. class Customer < ApplicationRecord
  5. self.table_name = "billing_customers"
  6. PROVIDERS = %w[lemonsqueezy].freeze
  7. belongs_to :user
  8. has_many :orders, class_name: "Billing::Order", foreign_key: :billing_customer_id, dependent: :nullify
  9. store_accessor :urls, :customer_portal_url, :latest_receipt_url
  10. validates :uuid, presence: true, uniqueness: true
  11. validates :provider, presence: true, inclusion: { in: PROVIDERS }
  12. validates :user_id, uniqueness: { scope: :provider }
  13. before_validation :ensure_uuid, on: :create
  14. private
  15. def ensure_uuid
  16. self.uuid ||= SecureRandom.uuid
  17. end
  18. end
  19. end

app/models/billing/entitlement_grant.rb

0.0% lines covered

100.0% branches covered

26 relevant lines. 0 lines covered and 26 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # A time-bounded entitlement override for a user (e.g., trials, promos, admin grants).
  4. #
  5. # `entitlements` is a JSON map keyed by feature_key:
  6. # { "pattern_detection" => { "enabled" => true }, "ai_summaries" => { "enabled" => true, "limit" => 50 } }
  7. class EntitlementGrant < ApplicationRecord
  8. self.table_name = "billing_entitlement_grants"
  9. SOURCES = %w[trial admin promo purchase].freeze
  10. belongs_to :user
  11. belongs_to :plan, class_name: "Billing::Plan", foreign_key: :billing_plan_id, optional: true
  12. validates :uuid, presence: true, uniqueness: true
  13. validates :source, presence: true, inclusion: { in: SOURCES }
  14. validates :starts_at, presence: true
  15. validates :expires_at, presence: true
  16. validate :validate_time_window
  17. before_validation :ensure_uuid, on: :create
  18. scope :active_at, ->(time) { where("starts_at <= ? AND expires_at > ?", time, time) }
  19. # @param time [Time]
  20. # @return [Boolean]
  21. def active?(time: Time.current)
  22. starts_at <= time && expires_at > time
  23. end
  24. private
  25. def ensure_uuid
  26. self.uuid ||= SecureRandom.uuid
  27. end
  28. def validate_time_window
  29. return if starts_at.blank? || expires_at.blank?
  30. errors.add(:expires_at, "must be after starts_at") if expires_at <= starts_at
  31. end
  32. end
  33. end

app/models/billing/feature.rb

0.0% lines covered

100.0% branches covered

21 relevant lines. 0 lines covered and 21 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # A feature flag or quota that can be granted by plans and/or explicit grants.
  4. class Feature < ApplicationRecord
  5. self.table_name = "billing_features"
  6. KINDS = %w[boolean quota].freeze
  7. has_many :plan_entitlements, class_name: "Billing::PlanEntitlement", dependent: :destroy, inverse_of: :feature
  8. has_many :plans, through: :plan_entitlements
  9. validates :uuid, presence: true, uniqueness: true
  10. validates :key, presence: true, uniqueness: { case_sensitive: true }
  11. validates :name, presence: true
  12. validates :kind, presence: true, inclusion: { in: KINDS }
  13. before_validation :ensure_uuid, on: :create
  14. after_commit :purge_catalog_cache
  15. private
  16. def ensure_uuid
  17. self.uuid ||= SecureRandom.uuid
  18. end
  19. def purge_catalog_cache
  20. Billing::Catalog.purge_cache!
  21. end
  22. end
  23. end

app/models/billing/order.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Stores LemonSqueezy order data for receipts and audit.
  4. class Order < ApplicationRecord
  5. self.table_name = "billing_orders"
  6. PROVIDERS = %w[lemonsqueezy].freeze
  7. belongs_to :user
  8. belongs_to :customer, class_name: "Billing::Customer", foreign_key: :billing_customer_id, optional: true
  9. belongs_to :subscription, class_name: "Billing::Subscription", foreign_key: :billing_subscription_id, optional: true
  10. validates :uuid, presence: true, uniqueness: true
  11. validates :provider, presence: true, inclusion: { in: PROVIDERS }
  12. validates :external_order_id, presence: true, uniqueness: { scope: :provider }
  13. before_validation :ensure_uuid, on: :create
  14. private
  15. # Ensures a UUID is assigned before validation.
  16. #
  17. # @return [void]
  18. def ensure_uuid
  19. self.uuid ||= SecureRandom.uuid
  20. end
  21. end
  22. end

app/models/billing/plan.rb

0.0% lines covered

100.0% branches covered

60 relevant lines. 0 lines covered and 60 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # A subscription plan displayed in the app and on public pricing pages.
  4. #
  5. # Plans are the source-of-truth for pricing/feature entitlements and are managed
  6. # through the internal developer portal.
  7. class Plan < ApplicationRecord
  8. self.table_name = "billing_plans"
  9. PLAN_TYPES = %w[free recurring one_time].freeze
  10. INTERVALS = %w[month year].freeze
  11. has_many :plan_entitlements, class_name: "Billing::PlanEntitlement", dependent: :destroy, inverse_of: :plan
  12. has_many :features, through: :plan_entitlements
  13. has_many :provider_mappings, class_name: "Billing::ProviderMapping", dependent: :destroy, inverse_of: :plan
  14. validates :uuid, presence: true, uniqueness: true
  15. validates :key, presence: true, uniqueness: { case_sensitive: true }
  16. validates :name, presence: true
  17. validates :plan_type, presence: true, inclusion: { in: PLAN_TYPES }
  18. validates :currency, presence: true
  19. validates :sort_order, numericality: { only_integer: true }
  20. validate :validate_interval_for_plan_type
  21. validate :validate_amount_for_plan_type
  22. before_validation :ensure_uuid, on: :create
  23. before_validation :normalize_metadata_json
  24. after_commit :purge_catalog_cache
  25. scope :published, -> { where(published: true) }
  26. scope :ordered, -> { order(sort_order: :asc, amount_cents: :asc, name: :asc) }
  27. # @return [Boolean] Whether this plan is the free tier.
  28. def free?
  29. plan_type == "free"
  30. end
  31. # @return [Boolean] Whether this plan is a recurring subscription (e.g. monthly).
  32. def recurring?
  33. plan_type == "recurring"
  34. end
  35. # @return [Boolean] Whether this plan is a one-time purchase (e.g. Sprint pass).
  36. def one_time?
  37. plan_type == "one_time"
  38. end
  39. private
  40. def ensure_uuid
  41. self.uuid ||= SecureRandom.uuid
  42. end
  43. def normalize_metadata_json
  44. return if metadata.blank?
  45. return if metadata.is_a?(Hash)
  46. # The developer portal JSON field can sometimes persist metadata as a JSON string.
  47. # If that happens, parse it back into an object so views/controllers don't treat
  48. # it like a Ruby String (e.g. `"..."["pricing_features"]` returning `"pricing_features"`).
  49. if metadata.is_a?(String)
  50. parsed = JSON.parse(metadata) rescue nil
  51. self.metadata = parsed if parsed.is_a?(Hash)
  52. end
  53. end
  54. def validate_interval_for_plan_type
  55. return if interval.blank?
  56. unless recurring?
  57. errors.add(:interval, "must be blank unless plan is recurring")
  58. return
  59. end
  60. errors.add(:interval, "must be month or year") unless INTERVALS.include?(interval)
  61. end
  62. def validate_amount_for_plan_type
  63. return if free?
  64. errors.add(:amount_cents, "must be present for paid plans") if amount_cents.blank?
  65. errors.add(:amount_cents, "must be >= 0") if amount_cents.present? && amount_cents.negative?
  66. end
  67. def purge_catalog_cache
  68. Billing::Catalog.purge_cache!
  69. end
  70. end
  71. end

app/models/billing/plan_entitlement.rb

0.0% lines covered

100.0% branches covered

23 relevant lines. 0 lines covered and 23 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Joins a Plan to a Feature and defines whether it's enabled and/or quota-limited.
  4. class PlanEntitlement < ApplicationRecord
  5. self.table_name = "billing_plan_entitlements"
  6. belongs_to :plan, class_name: "Billing::Plan", inverse_of: :plan_entitlements
  7. belongs_to :feature, class_name: "Billing::Feature", inverse_of: :plan_entitlements
  8. validates :plan_id, uniqueness: { scope: :feature_id }
  9. validate :validate_limit_for_feature_kind
  10. after_commit :purge_catalog_cache
  11. private
  12. def validate_limit_for_feature_kind
  13. return if feature.nil?
  14. # For quota features, a blank limit means "unlimited".
  15. # (We still allow setting an explicit numeric cap for Free/trials.)
  16. if feature.kind == "quota" && limit.present?
  17. errors.add(:limit, "must be >= 0") if limit.to_i.negative?
  18. end
  19. if feature.kind == "boolean" && limit.present?
  20. errors.add(:limit, "must be blank for boolean features")
  21. end
  22. end
  23. def purge_catalog_cache
  24. Billing::Catalog.purge_cache!
  25. end
  26. end
  27. end

app/models/billing/provider_mapping.rb

0.0% lines covered

100.0% branches covered

20 relevant lines. 0 lines covered and 20 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Maps an internal plan to a payment provider's identifiers (e.g. LemonSqueezy product/variant).
  4. class ProviderMapping < ApplicationRecord
  5. self.table_name = "billing_provider_mappings"
  6. PROVIDERS = %w[lemonsqueezy].freeze
  7. belongs_to :plan, class_name: "Billing::Plan", inverse_of: :provider_mappings
  8. validates :uuid, presence: true, uniqueness: true
  9. validates :provider, presence: true
  10. validates :provider, inclusion: { in: PROVIDERS }
  11. validates :plan_id, uniqueness: { scope: :provider }
  12. before_validation :ensure_uuid, on: :create
  13. after_commit :purge_catalog_cache
  14. private
  15. def ensure_uuid
  16. self.uuid ||= SecureRandom.uuid
  17. end
  18. def purge_catalog_cache
  19. Billing::Catalog.purge_cache!
  20. end
  21. end
  22. end

app/models/billing/subscription.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # A user's subscription state, synced from the payment provider via webhooks.
  4. class Subscription < ApplicationRecord
  5. self.table_name = "billing_subscriptions"
  6. PROVIDERS = %w[lemonsqueezy].freeze
  7. STATUSES = %w[active trialing cancelled expired past_due inactive].freeze
  8. belongs_to :user
  9. belongs_to :plan, class_name: "Billing::Plan", optional: true
  10. has_many :orders, class_name: "Billing::Order", foreign_key: :billing_subscription_id, dependent: :nullify
  11. store_accessor :urls,
  12. :customer_portal_url,
  13. :update_payment_method_url,
  14. :update_subscription_url,
  15. :latest_invoice_url,
  16. :latest_receipt_url
  17. validates :uuid, presence: true, uniqueness: true
  18. validates :provider, presence: true, inclusion: { in: PROVIDERS }
  19. validates :status, presence: true, inclusion: { in: STATUSES }
  20. before_validation :ensure_uuid, on: :create
  21. scope :active, -> { where(status: %w[active trialing]) }
  22. # @param at [Time]
  23. # @return [Boolean]
  24. def active_at?(at: Time.current)
  25. return true if status == "active"
  26. return true if status == "trialing" && trial_ends_at.present? && trial_ends_at > at
  27. current_period_ends_at.present? && current_period_ends_at > at
  28. end
  29. private
  30. def ensure_uuid
  31. self.uuid ||= SecureRandom.uuid
  32. end
  33. end
  34. end

app/models/billing/usage_counter.rb

0.0% lines covered

100.0% branches covered

32 relevant lines. 0 lines covered and 32 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Tracks usage for a given feature key and time window (for quota enforcement).
  4. class UsageCounter < ApplicationRecord
  5. self.table_name = "billing_usage_counters"
  6. belongs_to :user
  7. validates :uuid, presence: true, uniqueness: true
  8. validates :feature_key, presence: true
  9. validates :used, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
  10. validates :period_starts_at, presence: true
  11. validates :period_ends_at, presence: true
  12. validates :feature_key, uniqueness: { scope: [ :user_id, :period_starts_at ] }
  13. validate :validate_period
  14. before_validation :ensure_uuid, on: :create
  15. # Increments usage by a delta for the given period, creating the counter if needed.
  16. #
  17. # @param user [User]
  18. # @param feature_key [String]
  19. # @param period_starts_at [Time]
  20. # @param period_ends_at [Time]
  21. # @param delta [Integer]
  22. # @return [Billing::UsageCounter]
  23. def self.increment!(user:, feature_key:, period_starts_at:, period_ends_at:, delta: 1)
  24. counter = find_or_create_by!(user: user, feature_key: feature_key, period_starts_at: period_starts_at) do |c|
  25. c.period_ends_at = period_ends_at
  26. end
  27. counter.with_lock do
  28. counter.used += delta
  29. counter.save!
  30. end
  31. counter
  32. end
  33. private
  34. def ensure_uuid
  35. self.uuid ||= SecureRandom.uuid
  36. end
  37. def validate_period
  38. return if period_starts_at.blank? || period_ends_at.blank?
  39. errors.add(:period_ends_at, "must be after period_starts_at") if period_ends_at <= period_starts_at
  40. end
  41. end
  42. end

app/models/billing/webhook_event.rb

0.0% lines covered

100.0% branches covered

18 relevant lines. 0 lines covered and 18 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Stores raw webhook events from payment providers for idempotency and replay.
  4. class WebhookEvent < ApplicationRecord
  5. self.table_name = "billing_webhook_events"
  6. PROVIDERS = %w[lemonsqueezy].freeze
  7. STATUSES = %w[pending processed failed ignored].freeze
  8. validates :uuid, presence: true, uniqueness: true
  9. validates :provider, presence: true, inclusion: { in: PROVIDERS }
  10. validates :idempotency_key, presence: true, uniqueness: { scope: :provider }
  11. validates :status, presence: true, inclusion: { in: STATUSES }
  12. validates :received_at, presence: true
  13. before_validation :ensure_uuid, on: :create
  14. scope :pending, -> { where(status: "pending") }
  15. private
  16. def ensure_uuid
  17. self.uuid ||= SecureRandom.uuid
  18. end
  19. end
  20. end

app/models/blog_post.rb

0.0% lines covered

100.0% branches covered

44 relevant lines. 0 lines covered and 44 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # BlogPost represents a public-facing blog article managed via the custom admin panel.
  3. #
  4. # Content is stored as markdown-like text and rendered on the public blog pages.
  5. class BlogPost < ApplicationRecord
  6. extend FriendlyId
  7. STATUSES = %i[draft published].freeze
  8. acts_as_taggable_on :tags
  9. # Determine public storage service based on environment
  10. #
  11. # @return [Symbol] The storage service to use for public assets
  12. def self.public_storage_service
  13. case Rails.env
  14. when "production" then :cloudflare_public
  15. when "test" then :test_public
  16. else :local_public
  17. end
  18. end
  19. has_one_attached :cover_image, service: public_storage_service
  20. friendly_id :title, use: [ :slugged, :finders ]
  21. enum :status, STATUSES, default: :draft
  22. validates :title, presence: true
  23. validates :slug, presence: true, uniqueness: true
  24. validates :body, presence: true
  25. scope :published_publicly, -> { published.where.not(published_at: nil).where("published_at <= ?", Time.current) }
  26. scope :recent_first, -> { order(published_at: :desc, created_at: :desc) }
  27. # Generate a new slug when the title changes or when slug is blank.
  28. #
  29. # @return [Boolean]
  30. def should_generate_new_friendly_id?
  31. slug.blank? || will_save_change_to_title?
  32. end
  33. # Returns true when this post should be visible publicly.
  34. #
  35. # @return [Boolean]
  36. def publicly_visible?
  37. published? && published_at.present? && published_at <= Time.current
  38. end
  39. # Returns an optimized variant for the cover image.
  40. # Uses resize_to_limit to scale without cropping - preserves entire image.
  41. #
  42. # @param size [Symbol] :thumbnail, :medium, :large, :og
  43. # @return [ActiveStorage::Variant, ActiveStorage::Attached, nil]
  44. def cover_image_variant(size: :medium)
  45. return unless cover_image.attached?
  46. dimensions =
  47. case size
  48. when :thumbnail then [ 600, 400 ]
  49. when :medium then [ 1200, 800 ]
  50. when :large then [ 1600, 1067 ]
  51. when :og then [ 1200, 630 ] # OpenGraph standard - this one uses fill for social
  52. else
  53. nil
  54. end
  55. return cover_image if dimensions.nil?
  56. # OG images need exact dimensions for social sharing, others preserve aspect ratio
  57. if size == :og
  58. cover_image.variant(resize_to_fill: dimensions)
  59. else
  60. cover_image.variant(resize_to_limit: dimensions)
  61. end
  62. end
  63. end

app/models/category.rb

0.0% lines covered

100.0% branches covered

47 relevant lines. 0 lines covered and 47 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Category model used to group JobRoles and SkillTags with dedup-friendly semantics.
  3. class Category < ApplicationRecord
  4. include Disableable
  5. enum :kind, { job_role: 0, skill_tag: 1 }
  6. has_many :job_roles, dependent: :nullify
  7. has_many :skill_tags, dependent: :nullify
  8. has_many :interview_round_types, dependent: :nullify
  9. validates :name, presence: true
  10. validates :kind, presence: true
  11. normalizes :name, with: ->(name) { name.to_s.strip }
  12. scope :alphabetical, -> { order(:name) }
  13. scope :for_kind, ->(kind) { where(kind: kind) }
  14. scope :departments, -> { for_kind(:job_role).alphabetical }
  15. scope :skill_categories, -> { for_kind(:skill_tag).alphabetical }
  16. # Returns display name for the category
  17. # @return [String]
  18. def display_name
  19. name
  20. end
  21. # Alias for job_role categories (departments)
  22. # @return [Boolean]
  23. def department?
  24. job_role?
  25. end
  26. # Merges a source category into a target category
  27. #
  28. # @param source [Category] The category to be merged (will be deleted)
  29. # @param target [Category] The category to merge into
  30. # @return [Hash] Result hash with :success, :message/:error keys
  31. def self.merge_categories(source, target)
  32. if source == target
  33. return { success: false, error: "Cannot merge a category into itself." }
  34. end
  35. if source.nil? || target.nil?
  36. return { success: false, error: "Source or target category not found." }
  37. end
  38. if source.kind != target.kind
  39. return { success: false, error: "Cannot merge categories of different kinds (#{source.kind} vs #{target.kind})." }
  40. end
  41. stats = { job_roles: 0, skill_tags: 0 }
  42. transaction do
  43. # Transfer job_roles
  44. stats[:job_roles] = JobRole.where(category: source).update_all(category_id: target.id)
  45. # Transfer skill_tags
  46. stats[:skill_tags] = SkillTag.where(category: source).update_all(category_id: target.id)
  47. # Delete the source category
  48. source.destroy!
  49. end
  50. {
  51. success: true,
  52. message: "Transferred #{stats[:job_roles]} job roles and #{stats[:skill_tags]} skill tags."
  53. }
  54. rescue ActiveRecord::RecordNotDestroyed => e
  55. Rails.logger.error("Category merge failed - could not delete source: #{e.message}")
  56. { success: false, error: "Merge failed: Could not delete the source category. #{e.record.errors.full_messages.join(', ')}" }
  57. rescue => e
  58. Rails.logger.error("Category merge failed: #{e.class} - #{e.message}")
  59. { success: false, error: "Merge failed: #{e.message}" }
  60. end
  61. end

app/models/company.rb

0.0% lines covered

100.0% branches covered

65 relevant lines. 0 lines covered and 65 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Company model representing companies users apply to
  3. class Company < ApplicationRecord
  4. include Disableable
  5. has_many :job_listings, dependent: :destroy
  6. has_many :interview_applications, dependent: :nullify
  7. has_many :users_with_current_company, class_name: "User", foreign_key: "current_company_id", dependent: :nullify
  8. has_many :user_target_companies, dependent: :destroy
  9. has_many :users_targeting, through: :user_target_companies, source: :user
  10. has_many :email_senders, dependent: :nullify
  11. has_many :auto_detected_email_senders, class_name: "EmailSender", foreign_key: "auto_detected_company_id", dependent: :nullify
  12. validates :name, presence: true, uniqueness: true
  13. normalizes :name, with: ->(name) { name.strip }
  14. normalizes :website, with: ->(website) { website&.strip }
  15. scope :alphabetical, -> { order(:name) }
  16. scope :with_logo, -> { where.not(logo_url: nil) }
  17. # Returns a display name for the company
  18. # @return [String] Company name
  19. def display_name
  20. name
  21. end
  22. # Checks if company has a logo
  23. # @return [Boolean] True if logo exists
  24. def has_logo?
  25. logo_url.present?
  26. end
  27. # Merges a source company into a target company
  28. #
  29. # @param source [Company] The company to be merged (will be deleted)
  30. # @param target [Company] The company to merge into
  31. # @return [Hash] Result hash with :success, :message/:error keys
  32. def self.merge_companies(source, target)
  33. if source == target
  34. return { success: false, error: "Cannot merge a company into itself." }
  35. end
  36. if source.nil? || target.nil?
  37. return { success: false, error: "Source or target company not found." }
  38. end
  39. stats = {
  40. job_listings: 0,
  41. interview_applications: 0,
  42. users_current: 0,
  43. user_targets: 0,
  44. email_senders: 0
  45. }
  46. transaction do
  47. # Transfer job_listings
  48. stats[:job_listings] = JobListing.where(company: source).update_all(company_id: target.id)
  49. # Transfer interview_applications
  50. stats[:interview_applications] = InterviewApplication.where(company: source).update_all(company_id: target.id)
  51. # Transfer users with current_company
  52. stats[:users_current] = User.where(current_company_id: source.id).update_all(current_company_id: target.id)
  53. # Handle duplicate user_target_companies
  54. duplicate_target_ids = UserTargetCompany.where(company: source)
  55. .joins("INNER JOIN user_target_companies utc2 ON user_target_companies.user_id = utc2.user_id")
  56. .where("utc2.company_id = ?", target.id)
  57. .pluck(:id)
  58. UserTargetCompany.where(id: duplicate_target_ids).delete_all
  59. # Transfer remaining user_target_companies
  60. stats[:user_targets] = UserTargetCompany.where(company: source).update_all(company_id: target.id)
  61. # Transfer email_senders
  62. stats[:email_senders] = EmailSender.where(company: source).update_all(company_id: target.id)
  63. EmailSender.where(auto_detected_company_id: source.id).update_all(auto_detected_company_id: target.id)
  64. # Delete the source company
  65. source.destroy!
  66. end
  67. {
  68. success: true,
  69. message: "Transferred #{stats[:job_listings]} job listings, #{stats[:interview_applications]} applications, " \
  70. "#{stats[:users_current]} current users, #{stats[:user_targets]} target users, " \
  71. "and #{stats[:email_senders]} email senders."
  72. }
  73. rescue ActiveRecord::RecordNotUnique => e
  74. Rails.logger.error("Company merge failed due to duplicate key: #{e.message}")
  75. { success: false, error: "Merge failed: Some records already exist on the target company." }
  76. rescue ActiveRecord::RecordNotDestroyed => e
  77. Rails.logger.error("Company merge failed - could not delete source: #{e.message}")
  78. { success: false, error: "Merge failed: Could not delete the source company. #{e.record.errors.full_messages.join(', ')}" }
  79. rescue => e
  80. Rails.logger.error("Company merge failed: #{e.class} - #{e.message}")
  81. { success: false, error: "Merge failed: #{e.message}" }
  82. end
  83. end

app/models/company_feedback.rb

0.0% lines covered

100.0% branches covered

42 relevant lines. 0 lines covered and 42 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # CompanyFeedback model representing overall feedback from company for entire application process
  3. class CompanyFeedback < ApplicationRecord
  4. FEEDBACK_TYPES = %w[rejection offer general withdrawal on_hold].freeze
  5. belongs_to :interview_application
  6. belongs_to :source_email, class_name: "SyncedEmail", optional: true, foreign_key: :source_email_id
  7. validates :interview_application, presence: true
  8. scope :recent, -> { order(received_at: :desc, created_at: :desc) }
  9. scope :with_rejection, -> { where.not(rejection_reason: nil) }
  10. scope :by_type, ->(type) { where(feedback_type: type) }
  11. # Checks if this is a rejection feedback
  12. # @return [Boolean] True if rejection reason exists
  13. def rejection?
  14. rejection_reason.present?
  15. end
  16. # Checks if feedback has been received
  17. # @return [Boolean] True if received_at is set
  18. def received?
  19. received_at.present?
  20. end
  21. # Returns a summary of the feedback
  22. # @return [String] Feedback summary
  23. def summary
  24. feedback_text.presence || "No feedback yet"
  25. end
  26. # Checks if feedback has next steps
  27. # @return [Boolean] True if next steps exist
  28. def has_next_steps?
  29. next_steps.present?
  30. end
  31. # Returns sentiment of the feedback
  32. # @return [String] Sentiment (positive, negative, neutral)
  33. def sentiment
  34. return "negative" if rejection?
  35. return "positive" if has_next_steps?
  36. "neutral"
  37. end
  38. # Checks if this feedback is from an email
  39. # @return [Boolean] True if from email
  40. def from_email?
  41. source_email_id.present?
  42. end
  43. # Returns friendly feedback type name
  44. # @return [String] Feedback type display name
  45. def feedback_type_display
  46. case feedback_type
  47. when "rejection" then "Rejection"
  48. when "offer" then "Job Offer"
  49. when "general" then "General Feedback"
  50. when "withdrawal" then "Position Withdrawn"
  51. when "on_hold" then "On Hold"
  52. else feedback_type&.titleize || "Unknown"
  53. end
  54. end
  55. # Checks if this is an offer feedback
  56. # @return [Boolean] True if offer
  57. def offer?
  58. feedback_type == "offer"
  59. end
  60. end

app/models/concerns/disableable.rb

72.73% lines covered

100.0% branches covered

11 relevant lines. 8 lines covered and 3 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Adds a soft-disable mechanism to a model via a `disabled_at` timestamp.
  3. #
  4. # Usage:
  5. # class Company < ApplicationRecord
  6. # include Disableable
  7. # end
  8. #
  9. # Provides:
  10. # - `enabled` / `disabled` scopes
  11. # - `disabled?`
  12. # - `disable!` / `enable!`
  13. 1 module Disableable
  14. 1 extend ActiveSupport::Concern
  15. 1 included do
  16. 1 scope :enabled, -> { where(disabled_at: nil) }
  17. 1 scope :disabled, -> { where.not(disabled_at: nil) }
  18. end
  19. 1 def disabled?
  20. disabled_at.present?
  21. end
  22. 1 def disable!
  23. update!(disabled_at: Time.current)
  24. end
  25. 1 def enable!
  26. update!(disabled_at: nil)
  27. end
  28. end

app/models/concerns/transitionable.rb

77.78% lines covered

0.0% branches covered

9 relevant lines. 7 lines covered and 2 lines missed.
2 total branches, 0 branches covered and 2 branches missed.
    
  1. 1 module Transitionable
  2. 1 extend ActiveSupport::Concern
  3. 1 included do
  4. 2 include AASM
  5. 2 has_many :transitions, as: :resource, dependent: :destroy
  6. end
  7. 1 def transitioned?(status)
  8. transitions.pluck(:from_state).include? status
  9. end
  10. 1 def transitioned_at(status)
  11. then: 0 else: 0 transitions.where(from_state: status.to_s.downcase).last&.created_at
  12. end
  13. end

app/models/connected_account.rb

0.0% lines covered

100.0% branches covered

74 relevant lines. 0 lines covered and 74 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # ConnectedAccount model for storing OAuth credentials from external providers
  3. # Supports Gmail, and future integrations like LinkedIn, Outlook
  4. class ConnectedAccount < ApplicationRecord
  5. PROVIDERS = %w[google_oauth2].freeze
  6. belongs_to :user
  7. has_many :synced_emails, dependent: :destroy
  8. # Encrypt sensitive token data at rest
  9. encrypts :access_token, deterministic: false
  10. encrypts :refresh_token, deterministic: false
  11. # Validations
  12. validates :provider, presence: true, inclusion: { in: PROVIDERS }
  13. validates :uid, presence: true
  14. # Allow multiple accounts per user (removed user_id+provider uniqueness)
  15. # But prevent same Google account (provider+uid) from being connected to multiple users
  16. validates :provider, uniqueness: { scope: :uid, message: "account already connected to another user" }
  17. # Scopes
  18. scope :google, -> { where(provider: "google_oauth2") }
  19. scope :sync_enabled, -> { where(sync_enabled: true) }
  20. scope :expired, -> { where("expires_at < ?", Time.current) }
  21. scope :valid_tokens, -> { where("expires_at > ? OR expires_at IS NULL", Time.current) }
  22. scope :needs_reauth, -> { where(needs_reauth: true) }
  23. scope :ready_for_sync, -> { where(needs_reauth: false) }
  24. scope :expiring_soon, -> { where("expires_at < ?", 1.hour.from_now) }
  25. # Checks if the access token is expired
  26. # @return [Boolean]
  27. def token_expired?
  28. expires_at.present? && expires_at < Time.current
  29. end
  30. # Checks if the token will expire soon (within 5 minutes)
  31. # @return [Boolean]
  32. def token_expiring_soon?
  33. expires_at.present? && expires_at < 5.minutes.from_now
  34. end
  35. # Checks if we can refresh the token
  36. # @return [Boolean]
  37. def refreshable?
  38. refresh_token.present?
  39. end
  40. # Returns true if this is a Google account
  41. # @return [Boolean]
  42. def google?
  43. provider == "google_oauth2"
  44. end
  45. # Updates tokens from OAuth response
  46. # @param auth [OmniAuth::AuthHash] The OAuth response
  47. # @return [Boolean]
  48. def update_from_oauth(auth)
  49. update(
  50. access_token: auth.credentials.token,
  51. refresh_token: auth.credentials.refresh_token || refresh_token,
  52. expires_at: auth.credentials.expires_at ? Time.at(auth.credentials.expires_at) : nil,
  53. email: auth.info.email
  54. )
  55. end
  56. # Creates or updates a connected account from OAuth response
  57. # @param user [User] The user to connect
  58. # @param auth [OmniAuth::AuthHash] The OAuth response
  59. # @return [ConnectedAccount]
  60. def self.from_oauth(user, auth)
  61. account = user.connected_accounts.find_or_initialize_by(
  62. provider: auth.provider,
  63. uid: auth.uid
  64. )
  65. account.assign_attributes(
  66. access_token: auth.credentials.token,
  67. refresh_token: auth.credentials.refresh_token || account.refresh_token,
  68. expires_at: auth.credentials.expires_at ? Time.at(auth.credentials.expires_at) : nil,
  69. email: auth.info.email,
  70. scopes: auth.credentials.scope,
  71. # Clear reauth flags on successful reconnection
  72. needs_reauth: false,
  73. auth_error_at: nil,
  74. auth_error_message: nil,
  75. # Re-enable sync if it was disabled due to auth failure
  76. sync_enabled: account.sync_enabled? || account.needs_reauth?
  77. )
  78. account.save!
  79. account
  80. end
  81. # Mark the account as synced
  82. # @return [Boolean]
  83. def mark_synced!
  84. update(last_synced_at: Time.current)
  85. end
  86. # Mark the account as needing reauthorization
  87. # @param error_message [String, nil] Optional error message
  88. # @return [Boolean]
  89. def mark_needs_reauth!(error_message = nil)
  90. update!(
  91. needs_reauth: true,
  92. auth_error_at: Time.current,
  93. auth_error_message: error_message,
  94. sync_enabled: false
  95. )
  96. end
  97. # Clear the reauth requirement
  98. # @return [Boolean]
  99. def clear_reauth!
  100. update!(
  101. needs_reauth: false,
  102. auth_error_at: nil,
  103. auth_error_message: nil
  104. )
  105. end
  106. end

app/models/current.rb

100.0% lines covered

100.0% branches covered

3 relevant lines. 3 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. 1 class Current < ActiveSupport::CurrentAttributes
  2. 1 attribute :session
  3. 1 delegate :user, to: :session, allow_nil: true
  4. end

app/models/developer.rb

0.0% lines covered

100.0% branches covered

34 relevant lines. 0 lines covered and 34 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Developer model for TechWright SSO authenticated users
  3. #
  4. # Stores developer accounts that can access the internal admin portal.
  5. # Completely separate from the User model - developers authenticate
  6. # via TechWright SSO and don't need a User account.
  7. #
  8. # @example Finding or creating a developer from OAuth
  9. # developer = Developer.find_or_create_from_omniauth(auth)
  10. # developer.record_login!(ip_address: request.remote_ip)
  11. #
  12. class Developer < ApplicationRecord
  13. # Encrypt sensitive OAuth tokens at rest
  14. encrypts :access_token, :refresh_token
  15. # Validations
  16. validates :techwright_uid, presence: true, uniqueness: true
  17. validates :email, presence: true
  18. # Scopes
  19. scope :enabled, -> { where(enabled: true) }
  20. scope :disabled, -> { where(enabled: false) }
  21. scope :recently_active, -> { where("last_login_at > ?", 30.days.ago) }
  22. # Finds or creates a Developer from OmniAuth authentication data
  23. #
  24. # @param auth [OmniAuth::AuthHash] The OAuth authentication data from TechWright
  25. # @return [Developer] The found or created developer record
  26. def self.find_or_create_from_omniauth(auth)
  27. developer = find_or_initialize_by(techwright_uid: auth.uid)
  28. developer.update!(
  29. email: auth.info.email,
  30. name: auth.info.name,
  31. avatar_url: auth.info.image,
  32. access_token: auth.credentials.token,
  33. refresh_token: auth.credentials.refresh_token,
  34. token_expires_at: auth.credentials.expires_at ? Time.at(auth.credentials.expires_at) : nil
  35. )
  36. developer
  37. end
  38. # Records a login event for audit purposes
  39. #
  40. # @param ip_address [String] The IP address of the login request
  41. # @return [Boolean] True if the update was successful
  42. def record_login!(ip_address:)
  43. update!(
  44. last_login_at: Time.current,
  45. last_login_ip: ip_address,
  46. login_count: (login_count || 0) + 1
  47. )
  48. end
  49. # Checks if the developer account is enabled
  50. #
  51. # @return [Boolean] True if the developer can access the admin portal
  52. def enabled?
  53. enabled
  54. end
  55. # Checks if the OAuth token has expired
  56. #
  57. # @return [Boolean] True if the token has expired or will expire within 5 minutes
  58. def token_expired?
  59. return true if token_expires_at.nil?
  60. token_expires_at < 5.minutes.from_now
  61. end
  62. end

app/models/domain.rb

0.0% lines covered

100.0% branches covered

18 relevant lines. 0 lines covered and 18 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Domain model representing professional domains/industries (e.g., FinTech, SaaS, Healthcare)
  3. # Used for user targeting and resume analysis
  4. class Domain < ApplicationRecord
  5. include Disableable
  6. has_many :user_target_domains, dependent: :destroy
  7. has_many :users_targeting, through: :user_target_domains, source: :user
  8. validates :name, presence: true, uniqueness: true
  9. normalizes :name, with: ->(name) { name.to_s.strip }
  10. normalizes :slug, with: ->(slug) { slug.to_s.strip.downcase.gsub(/\s+/, "-").gsub(/[^a-z0-9\-]/, "") }
  11. before_validation :generate_slug, if: -> { slug.blank? && name.present? }
  12. scope :alphabetical, -> { order(:name) }
  13. scope :search, ->(query) { where("name ILIKE ?", "%#{query}%") if query.present? }
  14. # Returns a display name for the domain
  15. # @return [String] Domain name
  16. def display_name
  17. name
  18. end
  19. private
  20. # Generates a URL-friendly slug from the name
  21. # @return [void]
  22. def generate_slug
  23. self.slug = name.to_s.strip.downcase.gsub(/\s+/, "-").gsub(/[^a-z0-9\-]/, "")
  24. end
  25. end

app/models/email_sender.rb

0.0% lines covered

100.0% branches covered

81 relevant lines. 0 lines covered and 81 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # EmailSender model for tracking unique email addresses and associating them with companies
  3. # This helps build a contacts database for interview-related communications
  4. #
  5. # @example
  6. # sender = EmailSender.find_or_create_from_email("recruiter@company.com", "Jane Doe")
  7. # sender.assign_company!(company)
  8. #
  9. class EmailSender < ApplicationRecord
  10. SENDER_TYPES = %w[recruiter hiring_manager hr ats_system company unknown].freeze
  11. belongs_to :company, optional: true
  12. belongs_to :auto_detected_company, class_name: "Company", optional: true
  13. has_many :synced_emails, dependent: :nullify
  14. # Validations
  15. validates :email, presence: true, uniqueness: { case_sensitive: false }
  16. validates :domain, presence: true
  17. validates :sender_type, inclusion: { in: SENDER_TYPES }, allow_nil: true
  18. # Normalizations
  19. normalizes :email, with: ->(email) { email.strip.downcase }
  20. normalizes :domain, with: ->(domain) { domain.strip.downcase }
  21. # Scopes
  22. scope :unassigned, -> { where(company_id: nil) }
  23. scope :assigned, -> { where.not(company_id: nil) }
  24. scope :verified, -> { where(verified: true) }
  25. scope :unverified, -> { where(verified: false) }
  26. scope :auto_detected, -> { where.not(auto_detected_company_id: nil).where(company_id: nil) }
  27. scope :by_domain, ->(domain) { where(domain: domain.downcase) }
  28. scope :recent, -> { order(last_seen_at: :desc) }
  29. scope :most_active, -> { order(email_count: :desc) }
  30. scope :alphabetical, -> { order(:email) }
  31. # Callbacks
  32. before_validation :extract_domain, if: -> { email.present? && domain.blank? }
  33. before_validation :detect_sender_type, if: -> { sender_type.blank? }
  34. # Finds or creates an EmailSender from an email address
  35. #
  36. # @param email [String] The email address
  37. # @param name [String, nil] The sender's display name
  38. # @return [EmailSender]
  39. def self.find_or_create_from_email(email, name = nil)
  40. return nil if email.blank?
  41. sender = find_or_initialize_by(email: email.strip.downcase)
  42. sender.name = name if name.present? && sender.name.blank?
  43. sender.last_seen_at = Time.current
  44. sender.email_count = (sender.email_count || 0) + 1 unless sender.new_record?
  45. sender.save!
  46. sender
  47. rescue ActiveRecord::RecordNotUnique
  48. # Handle race condition
  49. find_by!(email: email.strip.downcase)
  50. end
  51. def self.sender_types_for_select
  52. SENDER_TYPES.map { |type| [ type.titleize, type ] }
  53. end
  54. # Increments the email count and updates last seen timestamp
  55. #
  56. # @return [Boolean]
  57. def record_email!
  58. increment!(:email_count)
  59. update!(last_seen_at: Time.current)
  60. end
  61. # Assigns a company to this sender (admin action)
  62. #
  63. # @param company [Company] The company to assign
  64. # @param verify [Boolean] Whether to mark as verified
  65. # @return [Boolean]
  66. def assign_company!(company, verify: true)
  67. update!(company: company, verified: verify)
  68. end
  69. # Returns the effective company (admin-assigned or auto-detected)
  70. #
  71. # @return [Company, nil]
  72. def effective_company
  73. company || auto_detected_company
  74. end
  75. # Checks if this sender has a company assigned
  76. #
  77. # @return [Boolean]
  78. def has_company?
  79. company_id.present? || auto_detected_company_id.present?
  80. end
  81. # Checks if this is from an ATS system
  82. #
  83. # @return [Boolean]
  84. def ats_system?
  85. sender_type == "ats_system"
  86. end
  87. # Returns a display name for the sender
  88. #
  89. # @return [String]
  90. def display_name
  91. name.presence || email
  92. end
  93. private
  94. # Extracts domain from email address
  95. #
  96. # @return [void]
  97. def extract_domain
  98. return unless email.present?
  99. self.domain = email.split("@").last&.downcase
  100. end
  101. # Detects sender type based on email patterns
  102. #
  103. # @return [void]
  104. def detect_sender_type
  105. return unless domain.present?
  106. self.sender_type = if ats_domain?
  107. "ats_system"
  108. elsif recruiter_pattern?
  109. "recruiter"
  110. elsif hr_pattern?
  111. "hr"
  112. else
  113. "unknown"
  114. end
  115. end
  116. # Checks if domain is from a known ATS system
  117. #
  118. # @return [Boolean]
  119. def ats_domain?
  120. Gmail::SyncService::RECRUITER_DOMAINS.any? { |d| domain.include?(d) }
  121. end
  122. # Checks if email matches recruiter patterns
  123. #
  124. # @return [Boolean]
  125. def recruiter_pattern?
  126. email.match?(/recruit|talent|sourcing/i)
  127. end
  128. # Checks if email matches HR patterns
  129. #
  130. # @return [Boolean]
  131. def hr_pattern?
  132. email.match?(/\bhr\b|human.?resources|people.?ops/i)
  133. end
  134. end

app/models/fit_assessment.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # FitAssessment model representing a user's fit score for a specific item.
  3. #
  4. # The fittable is polymorphic and is expected to be owned by the same user:
  5. # - Opportunity
  6. # - SavedJob
  7. # - InterviewApplication
  8. #
  9. # @example
  10. # FitAssessment.create!(user: user, fittable: opportunity, score: 82, status: :computed)
  11. #
  12. class FitAssessment < ApplicationRecord
  13. belongs_to :user
  14. belongs_to :fittable, polymorphic: true
  15. enum :status, { pending: 0, computed: 1, failed: 2 }, default: :pending
  16. validates :user, presence: true
  17. validates :fittable, presence: true
  18. validates :score,
  19. numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 100 },
  20. allow_nil: true
  21. validate :score_required_when_computed
  22. validate :fittable_owned_by_user
  23. private
  24. def score_required_when_computed
  25. return unless computed?
  26. return if score.present?
  27. errors.add(:score, "must be present when computed")
  28. end
  29. def fittable_owned_by_user
  30. return unless fittable && user
  31. return unless fittable.respond_to?(:user_id)
  32. return if fittable.user_id == user_id
  33. errors.add(:user, "must match the fittable's owner")
  34. end
  35. end

app/models/html_scraping_log.rb

0.0% lines covered

100.0% branches covered

113 relevant lines. 0 lines covered and 113 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # HtmlScrapingLog model for tracking field-level HTML extraction results
  3. #
  4. # Records detailed information about what the Nokogiri scraping step
  5. # was able to extract, which selectors matched, and extraction quality.
  6. #
  7. # @example
  8. # log = HtmlScrapingLog.create!(
  9. # scraping_attempt: attempt,
  10. # url: "https://example.com/job",
  11. # domain: "example.com",
  12. # field_results: {
  13. # title: { success: true, value: "Software Engineer", selector: "h1.job-title" },
  14. # location: { success: false, selectors_tried: ["[data-location]", ".location"] }
  15. # }
  16. # )
  17. class HtmlScrapingLog < ApplicationRecord
  18. STATUSES = [ :success, :partial, :failed ].freeze
  19. TRACKED_FIELDS = %w[
  20. title
  21. location
  22. remote_type
  23. salary_min
  24. salary_max
  25. salary_currency
  26. description
  27. company_name
  28. about_company
  29. company_culture
  30. requirements
  31. responsibilities
  32. benefits
  33. ].freeze
  34. # Associations
  35. belongs_to :scraping_attempt
  36. belongs_to :job_listing, optional: true
  37. # Enums
  38. enum :status, {
  39. success: 0, # All or most fields extracted
  40. partial: 1, # Some fields extracted
  41. failed: 2 # No fields extracted or error
  42. }, default: :partial
  43. # Validations
  44. validates :url, presence: true
  45. validates :domain, presence: true
  46. # Scopes
  47. scope :recent, -> { order(created_at: :desc) }
  48. scope :by_domain, ->(domain) { where(domain: domain) }
  49. scope :successful, -> { where(status: :success) }
  50. scope :failed, -> { where(status: :failed) }
  51. scope :recent_period, ->(days = 7) { where("created_at > ?", days.days.ago) }
  52. # Callbacks
  53. before_save :calculate_metrics
  54. # Returns fields that were successfully extracted
  55. #
  56. # @return [Array<String>] Field names
  57. def extracted_fields
  58. return [] unless field_results.is_a?(Hash)
  59. field_results.select { |_, v| v.is_a?(Hash) && v["success"] }.keys
  60. end
  61. # Returns fields that failed to extract
  62. #
  63. # @return [Array<String>] Field names
  64. def failed_fields
  65. return [] unless field_results.is_a?(Hash)
  66. field_results.reject { |_, v| v.is_a?(Hash) && v["success"] }.keys
  67. end
  68. # Returns extraction result for a specific field
  69. #
  70. # @param [String, Symbol] field_name The field name
  71. # @return [Hash, nil] Field result or nil
  72. def field_result(field_name)
  73. field_results[field_name.to_s]
  74. end
  75. # Checks if a field was successfully extracted
  76. #
  77. # @param [String, Symbol] field_name The field name
  78. # @return [Boolean] True if extracted
  79. def field_extracted?(field_name)
  80. result = field_result(field_name)
  81. result.is_a?(Hash) && result["success"]
  82. end
  83. # Returns the selector that matched for a field
  84. #
  85. # @param [String, Symbol] field_name The field name
  86. # @return [String, nil] Selector or nil
  87. def matched_selector(field_name)
  88. result = field_result(field_name)
  89. result["selector"] if result.is_a?(Hash)
  90. end
  91. # Returns formatted duration
  92. #
  93. # @return [String] Formatted duration
  94. def formatted_duration
  95. return "N/A" if duration_ms.nil?
  96. if duration_ms < 1000
  97. "#{duration_ms}ms"
  98. else
  99. "#{(duration_ms / 1000.0).round(2)}s"
  100. end
  101. end
  102. # Returns extraction rate as percentage
  103. #
  104. # @return [String] Formatted percentage
  105. def extraction_rate_display
  106. return "N/A" if extraction_rate.nil?
  107. "#{(extraction_rate * 100).round(0)}%"
  108. end
  109. # Returns status badge color
  110. #
  111. # @return [String] Tailwind color class
  112. def status_badge_color
  113. case status&.to_sym
  114. when :success then "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"
  115. when :partial then "bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
  116. when :failed then "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"
  117. else "bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-300"
  118. end
  119. end
  120. # Class method to calculate aggregate metrics for a domain
  121. #
  122. # @param [String] domain The domain
  123. # @param [Integer] days Number of days to look back
  124. # @return [Hash] Aggregate metrics
  125. def self.domain_metrics(domain, days: 7)
  126. logs = by_domain(domain).recent_period(days)
  127. return {} if logs.count.zero?
  128. # Calculate per-field success rates
  129. field_stats = {}
  130. TRACKED_FIELDS.each do |field|
  131. total = 0
  132. success = 0
  133. logs.find_each do |log|
  134. result = log.field_result(field)
  135. next unless result.is_a?(Hash)
  136. total += 1
  137. success += 1 if result["success"]
  138. end
  139. field_stats[field] = {
  140. total: total,
  141. success: success,
  142. rate: total > 0 ? (success.to_f / total * 100).round(1) : 0
  143. }
  144. end
  145. {
  146. total_attempts: logs.count,
  147. avg_extraction_rate: logs.average(:extraction_rate).to_f.round(2),
  148. avg_duration_ms: logs.average(:duration_ms).to_f.round(0),
  149. by_status: logs.group(:status).count,
  150. field_stats: field_stats
  151. }
  152. end
  153. private
  154. # Calculates summary metrics before save
  155. def calculate_metrics
  156. return unless field_results.is_a?(Hash)
  157. self.fields_attempted = field_results.keys.count
  158. self.fields_extracted = extracted_fields.count
  159. self.extraction_rate = fields_attempted > 0 ? fields_extracted.to_f / fields_attempted : 0.0
  160. # Determine status based on extraction rate
  161. self.status = if extraction_rate >= 0.7
  162. :success
  163. elsif extraction_rate > 0
  164. :partial
  165. else
  166. :failed
  167. end
  168. end
  169. end

app/models/interview_application.rb

63.33% lines covered

0.0% branches covered

150 relevant lines. 95 lines covered and 55 lines missed.
53 total branches, 0 branches covered and 53 branches missed.
    
  1. # frozen_string_literal: true
  2. # InterviewApplication model representing a job application tracking entry
  3. 1 class InterviewApplication < ApplicationRecord
  4. 1 include Transitionable
  5. 1 extend FriendlyId
  6. 1 friendly_id :uuid, use: [ :slugged, :finders ]
  7. 1 STATUSES = [ :active, :archived, :rejected, :accepted, :on_hold, :withdrawn ].freeze
  8. 1 PIPELINE_STAGES = [ :applied, :screening, :interviewing, :offer, :closed ].freeze
  9. 1 belongs_to :user
  10. 1 belongs_to :job_listing, optional: true
  11. 1 belongs_to :company
  12. 1 belongs_to :job_role
  13. 1 has_many :interview_rounds, dependent: :destroy, foreign_key: :interview_application_id
  14. 1 has_many :application_skill_tags, dependent: :destroy, foreign_key: :interview_id
  15. 1 has_many :skill_tags, through: :application_skill_tags
  16. 1 has_one :company_feedback, dependent: :destroy, foreign_key: :interview_application_id
  17. 1 has_many :synced_emails, dependent: :nullify
  18. 1 has_one :opportunity, dependent: :nullify
  19. 1 has_one :fit_assessment, as: :fittable, dependent: :destroy
  20. 1 has_many :interview_prep_artifacts, dependent: :destroy
  21. 1 validates :user, presence: true
  22. 1 validates :company, presence: true
  23. 1 validates :job_role, presence: true
  24. 1 scope :not_deleted, -> { where(deleted_at: nil) }
  25. 1 scope :deleted, -> { where.not(deleted_at: nil) }
  26. # Status state machine
  27. 1 aasm column: :status, with_klass: BaseAasm do
  28. 1 requires_guards!
  29. 1 log_transitions!
  30. 1 state :active, initial: true
  31. 1 state :archived
  32. 1 state :rejected
  33. 1 state :accepted
  34. 1 state :on_hold
  35. 1 state :withdrawn
  36. 1 event :archive do
  37. 1 transitions from: :active, to: :archived
  38. end
  39. 1 event :reject do
  40. 1 transitions from: :active, to: :rejected
  41. end
  42. 1 event :accept do
  43. 1 transitions from: :active, to: :accepted
  44. end
  45. 1 event :hold do
  46. 1 transitions from: :active, to: :on_hold
  47. end
  48. 1 event :withdraw do
  49. 1 transitions from: :active, to: :withdrawn
  50. end
  51. 1 event :reactivate do
  52. 1 transitions from: [ :archived, :rejected, :accepted, :on_hold, :withdrawn ], to: :active
  53. end
  54. end
  55. # Pipeline stage state machine
  56. 1 aasm :pipeline_stage, column: :pipeline_stage, with_klass: BaseAasm do
  57. 1 requires_guards!
  58. 1 log_transitions!
  59. 1 state :applied, initial: true
  60. 1 state :screening
  61. 1 state :interviewing
  62. 1 state :offer
  63. 1 state :closed
  64. 1 event :move_to_screening do
  65. 1 transitions from: [ :applied, :interviewing ], to: :screening
  66. end
  67. 1 event :move_to_interviewing do
  68. 1 transitions from: [ :applied, :screening, :offer ], to: :interviewing
  69. end
  70. 1 event :move_to_offer do
  71. 1 transitions from: [ :screening, :interviewing ], to: :offer
  72. end
  73. 1 event :move_to_closed do
  74. 1 transitions from: [ :applied, :screening, :interviewing, :offer ], to: :closed
  75. end
  76. 1 event :move_to_applied do
  77. 1 transitions from: [ :screening, :interviewing ], to: :applied
  78. end
  79. end
  80. 1 scope :recent, -> { order(created_at: :desc) }
  81. 1 scope :by_status, ->(status) { where(status: status) }
  82. 1 scope :by_pipeline_stage, ->(stage) { where(pipeline_stage: stage) }
  83. 1 scope :with_active_rounds, -> { joins(:interview_rounds).where(interview_rounds: { result: :pending }).distinct }
  84. # Set uuid early so FriendlyId can use it for slug generation
  85. # (FriendlyId runs in before_validation, before before_create)
  86. 1 before_validation :set_uuid, on: :create
  87. 1 before_create :set_applied_at
  88. # Returns a short summary for display in cards
  89. # @return [String] Summary text
  90. 1 def card_summary
  91. ai_summary.presence || "#{display_company.name} - #{display_job_role.title}"
  92. end
  93. # Returns the best available company for display
  94. #
  95. # Prefers the job_listing's company when extraction has completed and
  96. # produced a non-placeholder result. Falls back to the application's
  97. # own company association.
  98. #
  99. # @return [Company] The company to display
  100. 1 def display_company
  101. # If we have a job listing with extraction completed and valid company, use it
  102. then: 0 else: 0 then: 0 else: 0 if job_listing&.extraction_completed? && job_listing.company.present?
  103. jl_company = job_listing.company
  104. # Prefer job_listing's company unless it's also a placeholder
  105. else: 0 then: 0 unless placeholder_company?(jl_company)
  106. return jl_company
  107. end
  108. end
  109. # Fall back to application's own company
  110. company
  111. end
  112. # Returns the best available job role for display
  113. #
  114. # @return [JobRole] The job role to display
  115. 1 def display_job_role
  116. # If we have a job listing with extraction completed and valid job role, use it
  117. then: 0 else: 0 then: 0 else: 0 if job_listing&.extraction_completed? && job_listing.job_role.present?
  118. jl_role = job_listing.job_role
  119. else: 0 then: 0 unless placeholder_job_role?(jl_role)
  120. return jl_role
  121. end
  122. end
  123. # Fall back to application's own job role
  124. job_role
  125. end
  126. # Checks if this application has any interview rounds
  127. # @return [Boolean] True if rounds exist
  128. 1 def has_rounds?
  129. interview_rounds.exists?
  130. end
  131. # Returns the most recent interview round
  132. # @return [InterviewRound, nil] Most recent round or nil
  133. 1 def latest_round
  134. interview_rounds.ordered.last
  135. end
  136. # Returns count of completed rounds
  137. # @return [Integer] Count of completed rounds
  138. 1 def completed_rounds_count
  139. interview_rounds.completed.count
  140. end
  141. # Returns count of total rounds
  142. # @return [Integer] Total count of rounds
  143. 1 def total_rounds_count
  144. interview_rounds.count
  145. end
  146. # Checks if application has company feedback
  147. # @return [Boolean] True if company feedback exists
  148. 1 def has_company_feedback?
  149. company_feedback.present?
  150. end
  151. # Returns count of pending rounds
  152. # @return [Integer] Count of pending rounds
  153. 1 def pending_rounds_count
  154. interview_rounds.where(result: :pending).count
  155. end
  156. # Returns badge color for status
  157. # @return [String] Color name for badge
  158. 1 def status_badge_color
  159. when: 0 case status.to_sym
  160. when: 0 when :active then "blue"
  161. when: 0 when :accepted then "green"
  162. when: 0 when :rejected then "red"
  163. when: 0 when :archived then "gray"
  164. when: 0 when :on_hold then "yellow"
  165. else: 0 when :withdrawn then "gray"
  166. else "gray"
  167. end
  168. end
  169. # Returns formatted pipeline stage name
  170. # @return [String] Formatted stage name
  171. 1 def pipeline_stage_display
  172. pipeline_stage.to_s.titleize
  173. end
  174. # @return [Boolean] Whether this application is soft-deleted.
  175. 1 def deleted?
  176. deleted_at.present?
  177. end
  178. # Soft delete (move to trash). This does not destroy dependent records.
  179. #
  180. # @return [Boolean] true if persisted successfully
  181. 1 def soft_delete!
  182. then: 0 else: 0 return false if deleted?
  183. # Use update_columns to avoid triggering FriendlyId/AASM callbacks that may
  184. # regenerate slugs or block persistence for unrelated reasons.
  185. update_columns(deleted_at: Time.current, updated_at: Time.current)
  186. end
  187. # Restore a soft-deleted application.
  188. #
  189. # @return [Boolean] true if persisted successfully
  190. 1 def restore!
  191. else: 0 then: 0 return false unless deleted?
  192. update_columns(deleted_at: nil, updated_at: Time.current)
  193. end
  194. # Returns a scheduling link from synced emails if available
  195. # Prioritizes links from scheduling-type emails or high-priority action links
  196. #
  197. # @return [Hash, nil] { url: String, platform: String } or nil
  198. 1 def scheduling_link
  199. @scheduling_link ||= find_scheduling_link_from_emails
  200. end
  201. # Checks if this application has a scheduling link available
  202. #
  203. # @return [Boolean] True if scheduling link exists
  204. 1 def has_scheduling_link?
  205. scheduling_link.present?
  206. end
  207. # Checks if the next interview needs to be scheduled
  208. # True if no upcoming rounds exist
  209. #
  210. # @return [Boolean] True if interview not yet scheduled
  211. 1 def needs_scheduling?
  212. interview_rounds.upcoming.none?
  213. end
  214. # Returns scheduling link only if interview needs scheduling
  215. #
  216. # @return [Hash, nil] { url: String, platform: String } or nil
  217. 1 def actionable_scheduling_link
  218. else: 0 then: 0 return nil unless needs_scheduling?
  219. scheduling_link
  220. end
  221. 1 private
  222. # Finds scheduling link from synced emails
  223. #
  224. # @return [Hash, nil]
  225. 1 def find_scheduling_link_from_emails
  226. synced_emails.each do |email|
  227. else: 0 then: 0 next unless email.signal_action_links.is_a?(Array)
  228. # Look for scheduling links (priority 1 or label contains "schedule")
  229. email.signal_action_links.each do |link|
  230. then: 0 else: 0 then: 0 else: 0 then: 0 else: 0 if link["priority"] == 1 || link["action_label"]&.downcase&.include?("schedule")
  231. return {
  232. url: link["url"],
  233. platform: extract_platform_name(link["url"]),
  234. label: link["action_label"]
  235. }
  236. end
  237. end
  238. end
  239. nil
  240. end
  241. # Extracts friendly platform name from URL
  242. #
  243. # @param url [String]
  244. # @return [String]
  245. 1 def extract_platform_name(url)
  246. when: 0 case url
  247. when: 0 when /goodtime\.io/i then "GoodTime"
  248. when: 0 when /calendly\.com/i then "Calendly"
  249. when: 0 when /cal\.com/i then "Cal.com"
  250. when: 0 when /doodle\.com/i then "Doodle"
  251. else: 0 when /zoom\.us/i then "Zoom"
  252. else "scheduling link"
  253. end
  254. end
  255. 1 PLACEHOLDER_COMPANY_NAMES = [ "unknown company", "unknown" ].freeze
  256. 1 PLACEHOLDER_JOB_ROLES = [ "unknown position", "unknown role", "unknown" ].freeze
  257. 1 def placeholder_company?(comp)
  258. then: 0 else: 0 return true if comp.nil?
  259. then: 0 else: 0 then: 0 else: 0 PLACEHOLDER_COMPANY_NAMES.any? { |p| comp.name&.downcase&.include?(p) }
  260. end
  261. 1 def placeholder_job_role?(role)
  262. then: 0 else: 0 return true if role.nil?
  263. then: 0 else: 0 then: 0 else: 0 PLACEHOLDER_JOB_ROLES.any? { |p| role.title&.downcase&.include?(p) }
  264. end
  265. 1 def set_uuid
  266. self.uuid = SecureRandom.uuid
  267. end
  268. 1 def set_applied_at
  269. then: 0 else: 0 self.applied_at = Time.current if applied_at.blank?
  270. end
  271. end

app/models/interview_feedback.rb

0.0% lines covered

100.0% branches covered

25 relevant lines. 0 lines covered and 25 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # InterviewFeedback model representing self-reflection and notes for an interview round
  3. class InterviewFeedback < ApplicationRecord
  4. self.table_name = "interview_feedbacks"
  5. belongs_to :interview_round
  6. serialize :tags, coder: JSON
  7. attribute :tags, default: -> { [] }
  8. validates :interview_round, presence: true
  9. scope :recent, -> { order(created_at: :desc) }
  10. scope :with_recommendations, -> { where.not(recommended_action: nil) }
  11. # Returns tags as an array
  12. # @return [Array<String>] Array of tag strings
  13. def tag_list
  14. Array.wrap(tags)
  15. end
  16. # Sets tags from an array or comma-separated string
  17. # @param value [Array, String] Tags to set
  18. def tag_list=(value)
  19. self.tags = if value.is_a?(String)
  20. value.split(",").map(&:strip).reject(&:blank?)
  21. else
  22. Array.wrap(value)
  23. end
  24. end
  25. # Checks if this feedback has an AI summary
  26. # @return [Boolean] True if AI summary exists
  27. def has_ai_summary?
  28. ai_summary.present?
  29. end
  30. # Returns a short summary of what went well
  31. # @return [String] Truncated summary
  32. def summary_preview
  33. went_well.presence&.truncate(100) || "No feedback yet"
  34. end
  35. end

app/models/interview_prep_artifact.rb

0.0% lines covered

100.0% branches covered

25 relevant lines. 0 lines covered and 25 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # InterviewPrepArtifact stores cached, structured interview prep content for a specific application.
  3. #
  4. # Artifacts are generated per section (kind) and are idempotent via inputs_digest.
  5. class InterviewPrepArtifact < ApplicationRecord
  6. KINDS = [ :match_analysis, :focus_areas, :question_framing, :strength_positioning ].freeze
  7. STATUSES = [ :pending, :computed, :failed ].freeze
  8. belongs_to :interview_application
  9. belongs_to :user
  10. belongs_to :llm_api_log, class_name: "Ai::LlmApiLog", optional: true
  11. enum :kind, KINDS
  12. enum :status, STATUSES, default: :pending
  13. validates :uuid, presence: true, uniqueness: true
  14. validates :kind, presence: true, inclusion: { in: KINDS.map(&:to_s) }
  15. validates :status, presence: true, inclusion: { in: STATUSES.map(&:to_s) }
  16. validates :inputs_digest, presence: true
  17. validate :application_owned_by_user
  18. before_validation :ensure_uuid, on: :create
  19. scope :recent_first, -> { order(updated_at: :desc, created_at: :desc) }
  20. private
  21. def ensure_uuid
  22. self.uuid ||= SecureRandom.uuid
  23. end
  24. def application_owned_by_user
  25. return if interview_application.nil? || user.nil?
  26. return if interview_application.user_id == user_id
  27. errors.add(:user, "must match the interview application's owner")
  28. end
  29. end

app/models/interview_round.rb

0.0% lines covered

100.0% branches covered

84 relevant lines. 0 lines covered and 84 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # InterviewRound model representing individual interview rounds in an application process
  3. class InterviewRound < ApplicationRecord
  4. STAGES = [ :screening, :technical, :hiring_manager, :culture_fit, :other ].freeze
  5. RESULTS = [ :pending, :passed, :failed, :waitlisted, :cancelled ].freeze
  6. CONFIRMATION_SOURCES = %w[calendly goodtime greenhouse lever manual other].freeze
  7. belongs_to :interview_application
  8. belongs_to :source_email, class_name: "SyncedEmail", optional: true, foreign_key: :source_email_id
  9. belongs_to :interview_round_type, optional: true
  10. has_one :interview_feedback, dependent: :destroy
  11. has_many :prep_artifacts, class_name: "InterviewRoundPrepArtifact", dependent: :destroy
  12. enum :stage, STAGES, default: :screening
  13. enum :result, RESULTS, default: :pending
  14. validates :interview_application, presence: true
  15. validates :stage, presence: true, inclusion: { in: STAGES.map(&:to_s) }
  16. validates :result, inclusion: { in: RESULTS.map(&:to_s) }
  17. scope :by_stage, ->(stage) { where(stage: stage) }
  18. scope :completed, -> { where.not(completed_at: nil) }
  19. scope :upcoming, -> { where(completed_at: nil).where("scheduled_at > ?", Time.current) }
  20. scope :ordered, -> { order(position: :asc, scheduled_at: :asc, created_at: :asc) }
  21. # Returns display name for the stage
  22. # @return [String] Stage display name
  23. def stage_display_name
  24. stage_name.presence || stage.to_s.humanize
  25. end
  26. # Alias for stage_display_name for consistency
  27. alias_method :stage_display, :stage_display_name
  28. # Checks if round is completed
  29. # @return [Boolean] True if completed
  30. def completed?
  31. completed_at.present?
  32. end
  33. # Checks if round is upcoming
  34. # @return [Boolean] True if upcoming
  35. def upcoming?
  36. scheduled_at.present? && scheduled_at > Time.current && !completed?
  37. end
  38. # Returns duration in hours and minutes
  39. # @return [String, nil] Formatted duration
  40. def formatted_duration
  41. return nil if duration_minutes.nil?
  42. hours = duration_minutes / 60
  43. minutes = duration_minutes % 60
  44. if hours > 0 && minutes > 0
  45. "#{hours}h #{minutes}m"
  46. elsif hours > 0
  47. "#{hours}h 0m"
  48. else
  49. "#{minutes}m"
  50. end
  51. end
  52. # Returns badge color for result
  53. # @return [String] Color name for badge
  54. def result_badge_color
  55. case result.to_sym
  56. when :pending then "yellow"
  57. when :passed then "green"
  58. when :failed then "red"
  59. when :waitlisted then "blue"
  60. when :cancelled then "gray"
  61. else "gray"
  62. end
  63. end
  64. # Returns formatted interviewer information
  65. # @return [String, nil] Formatted interviewer info
  66. def interviewer_display
  67. return nil if interviewer_name.blank?
  68. return interviewer_name if interviewer_role.blank?
  69. "#{interviewer_name} (#{interviewer_role})"
  70. end
  71. # Checks if round has a video link
  72. # @return [Boolean] True if video link exists
  73. def has_video_link?
  74. video_link.present?
  75. end
  76. # Checks if round was created from email
  77. # @return [Boolean] True if created from email
  78. def from_email?
  79. source_email_id.present?
  80. end
  81. # Returns friendly confirmation source name
  82. # @return [String] Confirmation source display name
  83. def confirmation_source_display
  84. case confirmation_source
  85. when "calendly" then "Calendly"
  86. when "goodtime" then "GoodTime"
  87. when "greenhouse" then "Greenhouse"
  88. when "lever" then "Lever"
  89. when "manual" then "Direct Email"
  90. else confirmation_source&.titleize || "Unknown"
  91. end
  92. end
  93. # Returns the round type name for display
  94. # @return [String, nil] Round type name or nil if not set
  95. def round_type_name
  96. interview_round_type&.name
  97. end
  98. # Returns the round type slug for prep matching
  99. # @return [String, nil] Round type slug or nil if not set
  100. def round_type_slug
  101. interview_round_type&.slug
  102. end
  103. # Returns the comprehensive prep artifact if it exists and is completed
  104. # @return [InterviewRoundPrepArtifact, nil] The prep artifact or nil
  105. def prep
  106. prep_artifacts.completed.find_by(kind: :comprehensive)
  107. end
  108. # Checks if prep has been generated for this round
  109. # @return [Boolean] True if prep exists and is completed
  110. def has_prep?
  111. prep.present?
  112. end
  113. end

app/models/interview_round_prep_artifact.rb

0.0% lines covered

100.0% branches covered

44 relevant lines. 0 lines covered and 44 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # InterviewRoundPrepArtifact model for storing AI-generated interview preparation content.
  3. #
  4. # Each artifact stores a specific type of prep content (questions, strategies, patterns, tips)
  5. # for a specific interview round. Uses inputs_digest for cache invalidation.
  6. #
  7. # @example
  8. # artifact = InterviewRoundPrepArtifact.create!(
  9. # interview_round: round,
  10. # kind: :comprehensive,
  11. # content: { questions: [...], strategies: [...] },
  12. # status: :completed
  13. # )
  14. class InterviewRoundPrepArtifact < ApplicationRecord
  15. # Artifact kinds - types of prep content
  16. KINDS = [ :comprehensive, :questions, :strategies, :patterns, :tips, :checklist ].freeze
  17. # Status values for generation workflow
  18. STATUSES = [ :pending, :generating, :completed, :failed ].freeze
  19. # Associations
  20. belongs_to :interview_round
  21. # Enums
  22. enum :status, STATUSES, default: :pending
  23. # Validations
  24. validates :interview_round, presence: true
  25. validates :kind, presence: true, inclusion: { in: KINDS.map(&:to_s) }
  26. validates :kind, uniqueness: { scope: :interview_round_id, message: "already exists for this round" }
  27. # Store accessors for common content fields
  28. store_accessor :content,
  29. :round_summary,
  30. :expected_questions,
  31. :your_history,
  32. :company_patterns,
  33. :preparation_checklist,
  34. :answer_strategies,
  35. :tips
  36. # Scopes
  37. scope :by_kind, ->(kind) { where(kind: kind) }
  38. scope :completed, -> { where(status: :completed) }
  39. scope :recent, -> { order(generated_at: :desc) }
  40. # Checks if the artifact is stale based on inputs
  41. #
  42. # @param new_digest [String] The digest of current inputs
  43. # @return [Boolean] True if stale and needs regeneration
  44. def stale?(new_digest)
  45. inputs_digest != new_digest
  46. end
  47. # Marks the artifact as completed with content
  48. #
  49. # @param new_content [Hash] The generated content
  50. # @param digest [String] The inputs digest for cache invalidation
  51. # @return [Boolean] True if save succeeded
  52. def complete!(new_content, digest: nil)
  53. self.content = new_content
  54. self.inputs_digest = digest if digest
  55. self.generated_at = Time.current
  56. self.status = :completed
  57. save!
  58. end
  59. # Marks the artifact as failed
  60. #
  61. # @param error_message [String] Optional error message to store
  62. # @return [Boolean] True if save succeeded
  63. def fail!(error_message = nil)
  64. self.content = { error: error_message } if error_message
  65. self.status = :failed
  66. save!
  67. end
  68. # Returns the display name for the artifact kind
  69. #
  70. # @return [String] Human-readable kind name
  71. def kind_display_name
  72. kind.to_s.titleize
  73. end
  74. # Checks if the artifact has usable content
  75. #
  76. # @return [Boolean] True if completed with content
  77. def has_content?
  78. completed? && content.present? && !content.key?("error")
  79. end
  80. # Finds or initializes an artifact for a round and kind
  81. #
  82. # @param interview_round [InterviewRound] The round
  83. # @param kind [Symbol, String] The artifact kind
  84. # @return [InterviewRoundPrepArtifact] Found or new artifact
  85. def self.find_or_initialize_for(interview_round:, kind:)
  86. find_or_initialize_by(interview_round: interview_round, kind: kind)
  87. end
  88. end

app/models/interview_round_type.rb

0.0% lines covered

100.0% branches covered

29 relevant lines. 0 lines covered and 29 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # InterviewRoundType model representing granular interview round classifications.
  3. #
  4. # Round types are associated with departments (Categories) to enable per-department
  5. # customization. A nil category means the round type is universal (available to all).
  6. #
  7. # Examples: "Coding Interview", "System Design", "Behavioral", "Case Study"
  8. class InterviewRoundType < ApplicationRecord
  9. include Disableable
  10. # Associations
  11. belongs_to :category, optional: true
  12. has_many :interview_rounds, dependent: :nullify
  13. # Validations
  14. validates :name, presence: true
  15. validates :slug, presence: true, uniqueness: true
  16. # Normalizations
  17. normalizes :slug, with: ->(s) { s.to_s.parameterize.underscore }
  18. normalizes :name, with: ->(n) { n.to_s.strip }
  19. # Scopes
  20. scope :alphabetical, -> { order(:name) }
  21. scope :ordered, -> { order(:position, :name) }
  22. scope :universal, -> { where(category_id: nil) }
  23. scope :for_department, ->(cat_id) { where(category_id: [ nil, cat_id ]) }
  24. scope :search, ->(query) { where("name ILIKE ?", "%#{query}%") if query.present? }
  25. # Returns the display name for this round type
  26. #
  27. # @return [String] The round type name
  28. def display_name
  29. name
  30. end
  31. # Returns the department name if associated with one
  32. #
  33. # @return [String, nil] The department name or nil if universal
  34. def department_name
  35. category&.name
  36. end
  37. # Alias for department (category with kind: job_role)
  38. #
  39. # @return [Category, nil]
  40. def department
  41. category
  42. end
  43. # Checks if this round type is universal (available to all departments)
  44. #
  45. # @return [Boolean] True if universal
  46. def universal?
  47. category_id.nil?
  48. end
  49. # Finds a round type by slug
  50. #
  51. # @param slug [String] The slug to search for
  52. # @return [InterviewRoundType, nil] The round type or nil
  53. def self.find_by_slug(slug)
  54. find_by(slug: slug.to_s.parameterize.underscore)
  55. end
  56. end

app/models/job_listing.rb

47.12% lines covered

0.0% branches covered

104 relevant lines. 49 lines covered and 55 lines missed.
56 total branches, 0 branches covered and 56 branches missed.
    
  1. # frozen_string_literal: true
  2. # JobListing model representing job postings
  3. 1 class JobListing < ApplicationRecord
  4. 1 include Disableable
  5. 1 REMOTE_TYPES = [ :on_site, :hybrid, :remote ].freeze
  6. 1 STATUSES = [ :draft, :active, :closed ].freeze
  7. 1 EXTRACTION_QUALITIES = [ :full, :partial, :limited, :manual ].freeze
  8. 1 LIMITED_JOB_BOARDS = %w[linkedin indeed glassdoor].freeze
  9. 1 SALARY_CURRENCY_RE = /\A[A-Z]{3}\z/
  10. 1 MIN_ANNUAL_SALARY = 10_000
  11. 1 MAX_ANNUAL_SALARY = 2_000_000
  12. 1 belongs_to :company
  13. 1 belongs_to :job_role
  14. 1 has_many :interview_applications, dependent: :nullify
  15. 1 has_many :scraping_attempts, dependent: :destroy
  16. 1 has_many :scraped_job_listing_data, class_name: "ScrapedJobListingData", dependent: :destroy
  17. 1 has_many :llm_api_logs, class_name: "Ai::LlmApiLog", as: :loggable, dependent: :destroy
  18. 1 enum :remote_type, REMOTE_TYPES, default: :on_site
  19. 1 enum :status, STATUSES, default: :active
  20. 1 attribute :custom_sections, default: -> { {} }
  21. 1 attribute :scraped_data, default: -> { {} }
  22. 1 validates :company, presence: true
  23. 1 validates :job_role, presence: true
  24. 1 validates :remote_type, inclusion: { in: REMOTE_TYPES.map(&:to_s) }
  25. 1 validates :status, inclusion: { in: STATUSES.map(&:to_s) }
  26. 1 validate :url_has_safe_scheme, if: -> { url.present? }
  27. 1 scope :active, -> { where(status: :active) }
  28. 1 scope :closed, -> { where(status: :closed) }
  29. 1 scope :remote, -> { where(remote_type: :remote) }
  30. 1 scope :recent, -> { order(created_at: :desc) }
  31. # Returns a display title for the job listing
  32. # @return [String] Job listing title
  33. 1 def display_title
  34. title.presence || job_role.title
  35. end
  36. # Returns salary range as a formatted string
  37. # @return [String, nil] Formatted salary range
  38. 1 def salary_range
  39. else: 0 then: 0 return nil unless salary_range_valid?
  40. then: 0 else: 0 currency_symbol = salary_currency == "USD" ? "$" : salary_currency
  41. then: 0 else: 0 min_formatted = salary_min ? number_with_delimiter(salary_min.to_i) : nil
  42. then: 0 else: 0 max_formatted = salary_max ? number_with_delimiter(salary_max.to_i) : nil
  43. then: 0 if min_formatted && max_formatted
  44. else: 0 "#{currency_symbol}#{min_formatted} - #{currency_symbol}#{max_formatted} #{salary_currency}"
  45. then: 0 elsif min_formatted
  46. else: 0 "#{currency_symbol}#{min_formatted}+ #{salary_currency}"
  47. then: 0 else: 0 elsif max_formatted
  48. "Up to #{currency_symbol}#{max_formatted} #{salary_currency}"
  49. end
  50. end
  51. # Returns whether salary_min/salary_max are safe to display.
  52. #
  53. # This is intentionally conservative: if the extracted salary looks implausible
  54. # (too small, inverted range, invalid currency), we hide it.
  55. #
  56. # @return [Boolean]
  57. 1 def salary_range_valid?
  58. then: 0 else: 0 return false if salary_min.nil? && salary_max.nil?
  59. then: 0 else: 0 return false if salary_currency.blank? || salary_currency !~ SALARY_CURRENCY_RE
  60. then: 0 else: 0 min = salary_min&.to_f
  61. then: 0 else: 0 max = salary_max&.to_f
  62. then: 0 else: 0 return false if min && (min < MIN_ANNUAL_SALARY || min > MAX_ANNUAL_SALARY)
  63. then: 0 else: 0 return false if max && (max < MIN_ANNUAL_SALARY || max > MAX_ANNUAL_SALARY)
  64. then: 0 else: 0 return false if min && max && max < min
  65. true
  66. end
  67. # Checks if job listing has custom sections
  68. # @return [Boolean] True if custom sections exist
  69. 1 def has_custom_sections?
  70. custom_sections.present? && custom_sections.any?
  71. end
  72. # Checks if job listing was scraped
  73. # @return [Boolean] True if scraped data exists
  74. 1 def scraped?
  75. scraped_data.present? && scraped_data.any?
  76. end
  77. # Returns formatted remote type
  78. # @return [String] Formatted remote type
  79. 1 def remote_type_display
  80. remote_type.to_s.titleize.gsub("_", "-")
  81. end
  82. # Returns location with remote type
  83. # @return [String] Formatted location display
  84. 1 def location_display
  85. then: 0 if location.present?
  86. "#{location} (#{remote_type_display})"
  87. else: 0 else
  88. remote_type_display
  89. end
  90. end
  91. # Returns the latest scraping attempt
  92. # @return [ScrapingAttempt, nil] Latest attempt or nil
  93. 1 def latest_scraping_attempt
  94. scraping_attempts.order(created_at: :desc).first
  95. end
  96. # Returns extraction status from scraped_data
  97. # @return [String] Extraction status
  98. 1 def extraction_status
  99. scraped_data["status"] || "pending"
  100. end
  101. # Returns extraction confidence score
  102. # @return [Float] Confidence score between 0 and 1
  103. 1 def extraction_confidence
  104. scraped_data["confidence_score"] || 0.0
  105. end
  106. # Checks if extraction was successful
  107. # @return [Boolean] True if extraction completed successfully
  108. 1 def extraction_completed?
  109. extraction_status == "completed"
  110. end
  111. # Checks if extraction needs admin review
  112. # @return [Boolean] True if needs review
  113. 1 def extraction_needs_review?
  114. latest_attempt = latest_scraping_attempt
  115. else: 0 then: 0 return false unless latest_attempt
  116. latest_attempt.needs_review? || extraction_confidence < 0.7
  117. end
  118. # Returns the job board type from scraped_data
  119. # @return [String, nil] Job board type (linkedin, greenhouse, etc.)
  120. 1 def job_board
  121. scraped_data["job_board"] || job_board_id
  122. end
  123. # Returns extraction quality from scraped_data
  124. # @return [String] Extraction quality (full, partial, limited, manual)
  125. 1 def extraction_quality
  126. scraped_data["extraction_quality"] || "full"
  127. end
  128. # Checks if this job listing has limited extraction data
  129. # (from sources like LinkedIn that require auth)
  130. # @return [Boolean] True if extraction was limited
  131. 1 def limited_extraction?
  132. extraction_quality == "limited" || LIMITED_JOB_BOARDS.include?(job_board.to_s)
  133. end
  134. # Checks if this job listing needs more details from the user
  135. # @return [Boolean] True if more details would be helpful
  136. 1 def needs_more_details?
  137. then: 0 else: 0 return true if limited_extraction?
  138. then: 0 else: 0 return true if description.blank? && responsibilities.blank?
  139. then: 0 else: 0 return true if extraction_confidence < 0.5
  140. false
  141. end
  142. # Returns a human-readable explanation of why extraction was limited
  143. # @return [String, nil] Explanation or nil if not limited
  144. 1 def limited_extraction_reason
  145. else: 0 then: 0 return nil unless limited_extraction?
  146. case job_board.to_s
  147. when: 0 when "linkedin"
  148. "LinkedIn requires authentication to access full job details. " \
  149. "We extracted what was publicly available."
  150. when: 0 when "indeed"
  151. "Indeed limits public access to job details. " \
  152. "Some information may be incomplete."
  153. when: 0 when "glassdoor"
  154. "Glassdoor requires authentication for full job details. " \
  155. "We extracted what was publicly available."
  156. else: 0 else
  157. "This job listing has limited data due to source restrictions."
  158. end
  159. end
  160. # Returns a safe URL for linking, or nil if URL is potentially dangerous
  161. # Only allows http/https schemes to prevent javascript: XSS attacks
  162. #
  163. # @return [String, nil] Safe URL or nil
  164. 1 def safe_url
  165. then: 0 else: 0 return nil if url.blank?
  166. uri = URI.parse(url.strip)
  167. then: 0 else: 0 then: 0 else: 0 %w[http https].include?(uri.scheme&.downcase) ? url : nil
  168. rescue URI::InvalidURIError
  169. nil
  170. end
  171. 1 private
  172. # Validates that the URL uses a safe scheme (http/https only)
  173. # Prevents javascript:, data:, and other dangerous URL schemes
  174. #
  175. # @return [void]
  176. 1 def url_has_safe_scheme
  177. then: 0 else: 0 return if url.blank?
  178. begin
  179. uri = URI.parse(url.strip)
  180. then: 0 else: 0 else: 0 then: 0 unless %w[http https].include?(uri.scheme&.downcase)
  181. errors.add(:url, "must use http or https")
  182. end
  183. rescue URI::InvalidURIError
  184. errors.add(:url, "is not a valid URL")
  185. end
  186. end
  187. 1 def number_with_delimiter(number)
  188. number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
  189. end
  190. end

app/models/job_role.rb

0.0% lines covered

100.0% branches covered

74 relevant lines. 0 lines covered and 74 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # JobRole model representing job positions/titles
  3. class JobRole < ApplicationRecord
  4. include Disableable
  5. has_many :job_listings, dependent: :destroy
  6. has_many :interview_applications, dependent: :nullify
  7. has_many :users_with_current_role, class_name: "User", foreign_key: "current_job_role_id", dependent: :nullify
  8. has_many :user_target_job_roles, dependent: :destroy
  9. has_many :users_targeting, through: :user_target_job_roles, source: :user
  10. belongs_to :category, optional: true
  11. validates :title, presence: true, uniqueness: true
  12. normalizes :title, with: ->(title) { title.strip }
  13. scope :alphabetical, -> { order(:title) }
  14. scope :by_category, ->(category_id) { where(category_id: category_id) }
  15. scope :by_department, ->(department_id) { by_category(department_id) }
  16. scope :with_department, -> { includes(:category).where.not(category_id: nil) }
  17. scope :search, ->(query) { where("title ILIKE ?", "%#{query}%") if query.present? }
  18. def legacy_category_name
  19. respond_to?(:legacy_category) ? legacy_category : nil
  20. end
  21. # Returns a display name for the job role
  22. # @return [String] Job role title
  23. def display_name
  24. title
  25. end
  26. # Returns category name (alias: department name)
  27. # @return [String, nil]
  28. def category_name
  29. category&.name
  30. end
  31. # Alias for department (category with kind: job_role)
  32. # @return [Category, nil]
  33. def department
  34. category
  35. end
  36. # Returns department name
  37. # @return [String, nil]
  38. def department_name
  39. category&.name
  40. end
  41. # Returns available interview round types for this job role.
  42. # Includes universal types (no department) and types specific to this role's department.
  43. #
  44. # @return [ActiveRecord::Relation<InterviewRoundType>] Available round types
  45. def available_round_types
  46. InterviewRoundType.enabled.for_department(category_id).ordered
  47. end
  48. # Merges a source job role into a target job role
  49. #
  50. # @param source [JobRole] The job role to be merged (will be deleted)
  51. # @param target [JobRole] The job role to merge into
  52. # @return [Hash] Result hash with :success, :message/:error keys
  53. def self.merge_job_roles(source, target)
  54. if source == target
  55. return { success: false, error: "Cannot merge a job role into itself." }
  56. end
  57. if source.nil? || target.nil?
  58. return { success: false, error: "Source or target job role not found." }
  59. end
  60. stats = {
  61. job_listings: 0,
  62. interview_applications: 0,
  63. users_current: 0,
  64. user_targets: 0
  65. }
  66. transaction do
  67. # Transfer job_listings
  68. stats[:job_listings] = JobListing.where(job_role: source).update_all(job_role_id: target.id)
  69. # Transfer interview_applications
  70. stats[:interview_applications] = InterviewApplication.where(job_role: source).update_all(job_role_id: target.id)
  71. # Transfer users with current_job_role
  72. stats[:users_current] = User.where(current_job_role_id: source.id).update_all(current_job_role_id: target.id)
  73. # Handle duplicate user_target_job_roles
  74. duplicate_target_ids = UserTargetJobRole.where(job_role: source)
  75. .joins("INNER JOIN user_target_job_roles utjr2 ON user_target_job_roles.user_id = utjr2.user_id")
  76. .where("utjr2.job_role_id = ?", target.id)
  77. .pluck(:id)
  78. UserTargetJobRole.where(id: duplicate_target_ids).delete_all
  79. # Transfer remaining user_target_job_roles
  80. stats[:user_targets] = UserTargetJobRole.where(job_role: source).update_all(job_role_id: target.id)
  81. # Delete the source job role
  82. source.destroy!
  83. end
  84. {
  85. success: true,
  86. message: "Transferred #{stats[:job_listings]} job listings, #{stats[:interview_applications]} applications, " \
  87. "#{stats[:users_current]} current users, and #{stats[:user_targets]} target users."
  88. }
  89. rescue ActiveRecord::RecordNotUnique => e
  90. Rails.logger.error("JobRole merge failed due to duplicate key: #{e.message}")
  91. { success: false, error: "Merge failed: Some records already exist on the target job role." }
  92. rescue ActiveRecord::RecordNotDestroyed => e
  93. Rails.logger.error("JobRole merge failed - could not delete source: #{e.message}")
  94. { success: false, error: "Merge failed: Could not delete the source job role. #{e.record.errors.full_messages.join(', ')}" }
  95. rescue => e
  96. Rails.logger.error("JobRole merge failed: #{e.class} - #{e.message}")
  97. { success: false, error: "Merge failed: #{e.message}" }
  98. end
  99. end

app/models/llm_provider_config.rb

0.0% lines covered

100.0% branches covered

56 relevant lines. 0 lines covered and 56 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # LlmProviderConfig model for dynamic LLM provider configuration
  3. #
  4. # Allows runtime configuration of LLM providers without code deployment.
  5. # Admins can enable/disable providers, change models, adjust parameters, etc.
  6. class LlmProviderConfig < ApplicationRecord
  7. PROVIDER_TYPES = [:openai, :anthropic, :ollama, :gemini].freeze
  8. # Validations
  9. validates :name, presence: true
  10. validates :provider_type, presence: true, inclusion: { in: PROVIDER_TYPES.map(&:to_s) }
  11. validates :llm_model, presence: true
  12. validates :max_tokens, numericality: { greater_than: 0, less_than_or_equal_to: 100000 }, allow_nil: true
  13. validates :temperature, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 2 }, allow_nil: true
  14. validates :priority, numericality: { only_integer: true }, allow_nil: true
  15. # Scopes
  16. scope :enabled, -> { where(enabled: true) }
  17. scope :disabled, -> { where(enabled: false) }
  18. scope :by_priority, -> { order(priority: :asc, created_at: :asc) }
  19. scope :by_provider_type, ->(type) { where(provider_type: type) }
  20. # Returns all enabled providers in priority order
  21. #
  22. # @return [ActiveRecord::Relation] Enabled providers
  23. def self.active_providers
  24. enabled.by_priority
  25. end
  26. # Returns the default provider (highest priority enabled)
  27. #
  28. # @return [LlmProviderConfig, nil] Default provider or nil
  29. def self.default_provider
  30. active_providers.first
  31. end
  32. # Returns fallback providers (all except default)
  33. #
  34. # @return [ActiveRecord::Relation] Fallback providers
  35. def self.fallback_providers
  36. active_providers.offset(1)
  37. end
  38. # Checks if API key is configured for this provider
  39. #
  40. # @return [Boolean] True if API key exists
  41. def api_key_configured?
  42. api_key.present?
  43. end
  44. # Returns the API key from Rails credentials
  45. #
  46. # @return [String, nil] API key or nil
  47. def api_key
  48. case provider_type.to_sym
  49. when :openai
  50. Rails.application.credentials.dig(:openai, :api_key)
  51. when :anthropic
  52. Rails.application.credentials.dig(:anthropic, :api_key)
  53. when :gemini
  54. Rails.application.credentials.dig(:gemini, :api_key)
  55. when :ollama
  56. "local" # Ollama doesn't need an API key
  57. else
  58. nil
  59. end
  60. end
  61. # Checks if provider is ready to use
  62. #
  63. # @return [Boolean] True if enabled and has API key
  64. def ready?
  65. enabled? && api_key_configured?
  66. end
  67. # Returns display name with model info
  68. #
  69. # @return [String] Display name
  70. def display_name
  71. "#{name} (#{llm_model})"
  72. end
  73. # Returns configuration hash for provider instantiation
  74. #
  75. # @return [Hash] Configuration hash
  76. def to_config
  77. {
  78. provider_type: provider_type,
  79. model: llm_model,
  80. max_tokens: max_tokens,
  81. temperature: temperature,
  82. api_endpoint: api_endpoint,
  83. enabled: enabled,
  84. settings: settings
  85. }.compact
  86. end
  87. end

app/models/newsletter_subscriber.rb

0.0% lines covered

100.0% branches covered

5 relevant lines. 0 lines covered and 5 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # NewsletterSubscriber stores email-only newsletter signups.
  3. #
  4. # Mailkick subscriptions are polymorphic, so we use this model as the subscriber record.
  5. class NewsletterSubscriber < ApplicationRecord
  6. has_many :mailkick_subscriptions, as: :subscriber, class_name: "Mailkick::Subscription", dependent: :destroy
  7. normalizes :email, with: ->(e) { e.strip.downcase }
  8. validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  9. end

app/models/opportunity.rb

0.0% lines covered

100.0% branches covered

131 relevant lines. 0 lines covered and 131 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Opportunity model for tracking recruiter outreach emails
  3. # Captures job opportunities from emails before user decides to apply
  4. #
  5. # @example
  6. # opportunity = Opportunity.create!(user: user, company_name: "Stripe")
  7. # opportunity.apply! # Transitions to applied state
  8. #
  9. class Opportunity < ApplicationRecord
  10. include Transitionable
  11. # Status values stored as strings for readability
  12. STATUSES = %i[new reviewing applied archived].freeze
  13. SOURCE_TYPES = %w[direct_email linkedin_forward referral other].freeze
  14. # Associations
  15. belongs_to :user
  16. belongs_to :synced_email, optional: true
  17. belongs_to :interview_application, optional: true
  18. belongs_to :job_listing, optional: true
  19. has_one :saved_job, dependent: :destroy
  20. has_one :fit_assessment, as: :fittable, dependent: :destroy
  21. # Validations
  22. validates :user, presence: true
  23. validates :source_type, inclusion: { in: SOURCE_TYPES }, allow_nil: true
  24. # Store accessors for extracted_data
  25. store_accessor :extracted_data,
  26. :is_forwarded,
  27. :original_source,
  28. :raw_extraction
  29. # AASM state machine for status
  30. aasm column: :status, with_klass: BaseAasm do
  31. requires_guards!
  32. log_transitions!
  33. state :new, initial: true
  34. state :reviewing
  35. state :applied
  36. state :archived
  37. event :start_review do
  38. transitions from: :new, to: :reviewing
  39. end
  40. event :mark_applied do
  41. transitions from: [ :new, :reviewing ], to: :applied
  42. end
  43. event :archive_as_ignored do
  44. transitions from: [ :new, :reviewing ], to: :archived, after: :set_archived_as_ignored
  45. end
  46. event :reconsider do
  47. transitions from: :archived, to: :new, after: :clear_archived_metadata
  48. end
  49. end
  50. # Scopes
  51. scope :actionable, -> { where(status: %w[new reviewing]) }
  52. scope :archived, -> { where(status: "archived") }
  53. scope :recent, -> { order(created_at: :desc) }
  54. scope :by_status, ->(status) { where(status: status) }
  55. scope :with_job_url, -> { where.not(job_url: [ nil, "" ]) }
  56. scope :without_job_url, -> { where(job_url: [ nil, "" ]) }
  57. # Returns the display title for this opportunity
  58. #
  59. # @return [String] Title combining role and company
  60. def display_title
  61. parts = []
  62. parts << job_role_title if job_role_title.present?
  63. parts << "at #{company_name}" if company_name.present?
  64. parts.join(" ") || "New Opportunity"
  65. end
  66. # Returns the recruiter display name
  67. #
  68. # @return [String, nil] Recruiter name or email
  69. def recruiter_display
  70. recruiter_name.presence || recruiter_email
  71. end
  72. # Checks if this opportunity has a job URL
  73. #
  74. # @return [Boolean] True if job_url is present
  75. def has_job_url?
  76. job_url.present?
  77. end
  78. # Checks if this opportunity has extracted links
  79. #
  80. # @return [Boolean] True if extracted_links is not empty
  81. def has_extracted_links?
  82. extracted_links.present? && extracted_links.any?
  83. end
  84. # Returns extracted links as structured objects
  85. #
  86. # @return [Array<Hash>] Array of link hashes with url, type, description
  87. def parsed_links
  88. return [] unless extracted_links.is_a?(Array)
  89. extracted_links.map do |link|
  90. if link.is_a?(Hash)
  91. link.symbolize_keys
  92. else
  93. { url: link.to_s, type: "unknown", description: nil }
  94. end
  95. end
  96. end
  97. # Returns the primary job link (first job-related link found)
  98. #
  99. # @return [String, nil] The primary job URL
  100. def primary_job_link
  101. return job_url if job_url.present?
  102. job_link = parsed_links.find { |l| l[:type] == "job_posting" }
  103. job_link&.dig(:url)
  104. end
  105. # Checks if this is a forwarded email (e.g., from LinkedIn)
  106. #
  107. # @return [Boolean] True if the email was forwarded
  108. def forwarded?
  109. is_forwarded == true || source_type == "linkedin_forward"
  110. end
  111. # Returns badge classes for the status
  112. #
  113. # @return [String] Tailwind CSS classes
  114. def status_badge_classes
  115. case status
  116. when "new"
  117. "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
  118. when "reviewing"
  119. "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
  120. when "applied"
  121. "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
  122. when "archived"
  123. "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
  124. else
  125. "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
  126. end
  127. end
  128. # Returns icon name for source type
  129. #
  130. # @return [String] Icon identifier
  131. def source_type_icon
  132. case source_type
  133. when "linkedin_forward"
  134. "linkedin"
  135. when "referral"
  136. "users"
  137. when "direct_email"
  138. "mail"
  139. else
  140. "mail"
  141. end
  142. end
  143. # Returns human-readable source type
  144. #
  145. # @return [String] Display name
  146. def source_type_display
  147. case source_type
  148. when "linkedin_forward"
  149. "LinkedIn"
  150. when "referral"
  151. "Referral"
  152. when "direct_email"
  153. "Direct Email"
  154. else
  155. "Email"
  156. end
  157. end
  158. # Returns a short snippet for display
  159. #
  160. # @param length [Integer] Maximum length
  161. # @return [String] Truncated key details or email snippet
  162. def short_description(length = 100)
  163. text = key_details.presence || email_snippet
  164. text&.truncate(length) || ""
  165. end
  166. private
  167. # @return [void]
  168. def set_archived_as_ignored
  169. update_columns(
  170. archived_reason: "ignored",
  171. archived_at: Time.current
  172. )
  173. end
  174. # @return [void]
  175. def clear_archived_metadata
  176. update_columns(
  177. archived_reason: nil,
  178. archived_at: nil
  179. )
  180. end
  181. end

app/models/resume_skill.rb

0.0% lines covered

100.0% branches covered

63 relevant lines. 0 lines covered and 63 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # ResumeSkill model representing extracted skills from a specific resume
  3. #
  4. # Links a UserResume to a SkillTag with proficiency levels and evidence
  5. #
  6. # @example
  7. # resume_skill = ResumeSkill.create!(
  8. # user_resume: resume,
  9. # skill_tag: skill,
  10. # model_level: 4,
  11. # confidence_score: 0.85,
  12. # category: "Backend",
  13. # evidence_snippet: "5 years of Ruby on Rails experience"
  14. # )
  15. #
  16. class ResumeSkill < ApplicationRecord
  17. # Constants
  18. PROFICIENCY_LEVELS = (1..5).to_a.freeze
  19. CATEGORIES = %w[
  20. Backend
  21. Frontend
  22. Fullstack
  23. Infrastructure
  24. DevOps
  25. Data
  26. Mobile
  27. Leadership
  28. Communication
  29. ProjectManagement
  30. Design
  31. Security
  32. AI/ML
  33. Other
  34. ].freeze
  35. # Associations
  36. belongs_to :user_resume
  37. belongs_to :skill_tag
  38. # Delegations
  39. delegate :user, to: :user_resume
  40. delegate :name, to: :skill_tag, prefix: :skill
  41. # Validations
  42. validates :model_level, presence: true, inclusion: { in: PROFICIENCY_LEVELS }
  43. validates :user_level, inclusion: { in: PROFICIENCY_LEVELS }, allow_nil: true
  44. validates :confidence_score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
  45. validates :user_resume_id, uniqueness: { scope: :skill_tag_id, message: "skill already exists for this resume" }
  46. # Scopes
  47. scope :by_category, ->(category) { where(category: category) }
  48. scope :high_confidence, -> { where("confidence_score >= ?", 0.7) }
  49. scope :user_confirmed, -> { where.not(user_level: nil) }
  50. scope :alphabetical, -> { joins(:skill_tag).order("skill_tags.name ASC") }
  51. scope :by_proficiency, -> { order(Arel.sql("COALESCE(user_level, model_level) DESC")) }
  52. # Callbacks
  53. after_save :trigger_skill_aggregation
  54. after_destroy :trigger_skill_aggregation
  55. # Returns the effective proficiency level (user override or AI-assigned)
  56. #
  57. # @return [Integer] Proficiency level 1-5
  58. def effective_level
  59. user_level || model_level
  60. end
  61. # Checks if user has confirmed/adjusted this skill
  62. #
  63. # @return [Boolean]
  64. def user_confirmed?
  65. user_level.present?
  66. end
  67. # Sets the user-confirmed proficiency level
  68. #
  69. # @param level [Integer] Proficiency level 1-5
  70. # @return [Boolean]
  71. def confirm_level!(level)
  72. update!(user_level: level)
  73. end
  74. # Returns confidence as a percentage
  75. #
  76. # @return [Integer] Confidence percentage 0-100
  77. def confidence_percentage
  78. return 0 unless confidence_score
  79. (confidence_score * 100).round
  80. end
  81. # Returns a human-readable proficiency label
  82. #
  83. # @return [String] Proficiency description
  84. def proficiency_label
  85. case effective_level
  86. when 1 then "Beginner"
  87. when 2 then "Elementary"
  88. when 3 then "Intermediate"
  89. when 4 then "Advanced"
  90. when 5 then "Expert"
  91. else "Unknown"
  92. end
  93. end
  94. private
  95. # Triggers user skill aggregation after changes
  96. def trigger_skill_aggregation
  97. Resumes::SkillAggregationService.new(user).aggregate_skill(skill_tag)
  98. rescue => e
  99. Rails.logger.error("Failed to aggregate skill: #{e.message}")
  100. end
  101. end

app/models/resume_work_experience.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # ResumeWorkExperience represents one work experience extracted from a specific resume.
  3. # It stores rich work history (dates, responsibilities, highlights) and can be linked
  4. # to canonical Company/JobRole records when possible.
  5. class ResumeWorkExperience < ApplicationRecord
  6. belongs_to :user_resume
  7. belongs_to :company, optional: true
  8. belongs_to :job_role, optional: true
  9. has_many :resume_work_experience_skills, dependent: :destroy
  10. has_many :skill_tags, through: :resume_work_experience_skills
  11. scope :chronological, -> { order(Arel.sql("COALESCE(start_date, end_date) ASC NULLS LAST"), created_at: :asc) }
  12. scope :reverse_chronological, -> { order(Arel.sql("COALESCE(end_date, start_date) DESC NULLS LAST"), created_at: :desc) }
  13. # @return [String]
  14. def display_company_name
  15. company&.name.presence || company_name.to_s
  16. end
  17. # @return [String]
  18. def display_role_title
  19. job_role&.title.presence || role_title.to_s
  20. end
  21. end

app/models/resume_work_experience_skill.rb

0.0% lines covered

100.0% branches covered

6 relevant lines. 0 lines covered and 6 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Join model linking a ResumeWorkExperience to a SkillTag (skills used in that role).
  3. class ResumeWorkExperienceSkill < ApplicationRecord
  4. belongs_to :resume_work_experience
  5. belongs_to :skill_tag
  6. validates :resume_work_experience_id, uniqueness: { scope: :skill_tag_id }
  7. validates :confidence_score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }, allow_nil: true
  8. end

app/models/saved_job.rb

0.0% lines covered

100.0% branches covered

56 relevant lines. 0 lines covered and 56 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # SavedJob model representing a user-saved job lead.
  3. #
  4. # A saved job can be created from:
  5. # - an existing Opportunity (email-sourced lead), or
  6. # - a pasted URL (new job lead).
  7. #
  8. # Exactly one of `opportunity_id` or `url` must be present.
  9. #
  10. # @example Save from an opportunity
  11. # SavedJob.create!(user: user, opportunity: opportunity)
  12. #
  13. # @example Save from a URL
  14. # SavedJob.create!(user: user, url: "https://boards.greenhouse.io/acme/jobs/123")
  15. #
  16. class SavedJob < ApplicationRecord
  17. include Transitionable
  18. belongs_to :user
  19. belongs_to :opportunity, optional: true
  20. has_one :fit_assessment, as: :fittable, dependent: :destroy
  21. validates :user, presence: true
  22. validate :exactly_one_source
  23. validate :valid_url_format, if: -> { url.present? }
  24. aasm column: :status, with_klass: BaseAasm do
  25. requires_guards!
  26. log_transitions!
  27. state :active, initial: true
  28. state :archived
  29. event :archive_removed do
  30. transitions from: :active, to: :archived, after: :set_archived_as_removed
  31. end
  32. event :restore do
  33. transitions from: :archived, to: :active, after: :clear_archived_metadata
  34. end
  35. end
  36. scope :recent, -> { order(created_at: :desc) }
  37. scope :active, -> { where(status: "active") }
  38. scope :archived, -> { where(status: "archived") }
  39. scope :converted, -> { where.not(converted_at: nil) }
  40. scope :unconverted, -> { where(converted_at: nil) }
  41. # Returns the best URL for conversion or display.
  42. #
  43. # @return [String, nil]
  44. def effective_url
  45. url.presence || opportunity&.primary_job_link
  46. end
  47. private
  48. # @return [void]
  49. def set_archived_as_removed
  50. update_columns(
  51. archived_reason: "removed_saved_job",
  52. archived_at: Time.current
  53. )
  54. end
  55. # @return [void]
  56. def clear_archived_metadata
  57. update_columns(
  58. archived_reason: nil,
  59. archived_at: nil
  60. )
  61. end
  62. def exactly_one_source
  63. if opportunity_id.present? && url.present?
  64. errors.add(:base, "Saved job must have either an opportunity or a URL, not both")
  65. elsif opportunity_id.blank? && url.blank?
  66. errors.add(:base, "Saved job must have an opportunity or a URL")
  67. end
  68. end
  69. def valid_url_format
  70. uri = URI.parse(url)
  71. return if uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
  72. errors.add(:url, "must be a valid HTTP/HTTPS URL")
  73. rescue URI::InvalidURIError
  74. errors.add(:url, "must be a valid HTTP/HTTPS URL")
  75. end
  76. end

app/models/scraped_job_listing_data.rb

0.0% lines covered

100.0% branches covered

76 relevant lines. 0 lines covered and 76 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "digest"
  3. require "rack/utils"
  4. # ScrapedJobListingData model for caching HTML content with validity periods
  5. #
  6. # Stores fetched HTML content to avoid repeated network requests and enable
  7. # idempotent retries of extraction steps.
  8. class ScrapedJobListingData < ApplicationRecord
  9. VALIDITY_PERIOD_DAYS = 30
  10. TRACKING_QUERY_KEYS = %w[
  11. utm_source utm_medium utm_campaign utm_term utm_content
  12. gclid fbclid msclkid
  13. gh_src ccuid
  14. ].freeze
  15. belongs_to :job_listing
  16. belongs_to :scraping_attempt, optional: true
  17. validates :url, presence: true
  18. validates :valid_until, presence: true
  19. validates :content_hash, uniqueness: { scope: [ :url, :job_listing_id ] }, allow_nil: true
  20. scope :valid, -> { where("valid_until > ?", Time.current) }
  21. scope :expired, -> { where("valid_until <= ?", Time.current) }
  22. scope :for_url, ->(url) { where(url: normalize_url(url)) }
  23. # Finds or creates a valid cache entry for a URL
  24. #
  25. # @param [String] url The job listing URL
  26. # @param [JobListing] job_listing The job listing
  27. # @return [ScrapedJobListingData, nil] Cache entry or nil
  28. def self.find_valid_for_url(url, job_listing: nil)
  29. normalized_url = normalize_url(url)
  30. valid.for_url(normalized_url)
  31. .where(job_listing: job_listing)
  32. .order(valid_until: :desc)
  33. .first
  34. end
  35. # Creates a new cache entry with HTML content
  36. #
  37. # @param [String] url The job listing URL
  38. # @param [String] html_content The HTML content
  39. # @param [JobListing] job_listing The job listing
  40. # @param [ScrapingAttempt] scraping_attempt Optional scraping attempt
  41. # @param [Hash] metadata Additional fetch metadata
  42. # @return [ScrapedJobListingData] The created cache entry
  43. def self.create_with_html(url:, html_content:, job_listing:, scraping_attempt: nil, http_status: nil, metadata: {})
  44. normalized_url = normalize_url(url)
  45. content_hash = Digest::SHA256.hexdigest(html_content)
  46. cleaned_html = clean_html(html_content, url: url)
  47. record = find_or_initialize_by(job_listing: job_listing, url: normalized_url, content_hash: content_hash)
  48. record.html_content = html_content
  49. record.cleaned_html = cleaned_html
  50. record.http_status = http_status
  51. record.valid_until = VALIDITY_PERIOD_DAYS.days.from_now
  52. record.fetch_metadata = (record.fetch_metadata || {}).merge(metadata || {})
  53. record.scraping_attempt ||= scraping_attempt
  54. record.save!
  55. record
  56. end
  57. # Normalizes URL for consistent lookup
  58. #
  59. # @param [String] url The URL to normalize
  60. # @return [String] Normalized URL
  61. def self.normalize_url(url)
  62. uri = URI.parse(url)
  63. # Use canonical URL for boards with special URL formats (e.g., LinkedIn)
  64. detector = Scraping::JobBoardDetectorService.new(url)
  65. if detector.detect == :linkedin
  66. return detector.canonical_url.downcase
  67. end
  68. params = Rack::Utils.parse_query(uri.query.to_s)
  69. # Drop common marketing/tracking params, but keep params that define the resource
  70. # (e.g. Greenhouse `gh_jid`).
  71. params = params.reject { |k, _| TRACKING_QUERY_KEYS.include?(k.to_s) || k.to_s.start_with?("utm_") }
  72. normalized = +"#{uri.scheme}://#{uri.host}#{uri.path}"
  73. if params.any?
  74. normalized << "?" << URI.encode_www_form(params.sort_by { |k, _| k.to_s })
  75. end
  76. normalized.downcase
  77. rescue URI::InvalidURIError
  78. url.to_s.downcase
  79. end
  80. # Cleans HTML content for better extraction
  81. #
  82. # Uses board-specific cleaners when URL is known, otherwise falls back to generic.
  83. #
  84. # @param [String] html The raw HTML
  85. # @param [String, nil] url Optional URL to determine board-specific cleaner
  86. # @return [String] Cleaned HTML text
  87. def self.clean_html(html, url: nil)
  88. return "" if html.blank?
  89. cleaner = if url.present?
  90. Scraping::HtmlCleaners::CleanerFactory.cleaner_for_url(url)
  91. else
  92. Scraping::NokogiriHtmlCleanerService.new
  93. end
  94. cleaner.clean(html)
  95. end
  96. # Checks if this cache entry is still valid (not expired)
  97. #
  98. # @return [Boolean] True if valid
  99. def cache_valid?
  100. valid_until > Time.current
  101. end
  102. # Checks if this cache entry has expired
  103. #
  104. # @return [Boolean] True if expired
  105. def expired?
  106. !cache_valid?
  107. end
  108. # Marks this cache entry as expired
  109. #
  110. # @return [Boolean] True if updated
  111. def expire!
  112. update(valid_until: Time.current - 1.second)
  113. end
  114. # Returns the HTML content to use (cleaned if available, otherwise raw)
  115. #
  116. # @return [String] HTML content
  117. def html_for_extraction
  118. cleaned_html.presence || html_content
  119. end
  120. end

app/models/scraping_attempt.rb

67.53% lines covered

0.0% branches covered

77 relevant lines. 52 lines covered and 25 lines missed.
14 total branches, 0 branches covered and 14 branches missed.
    
  1. # frozen_string_literal: true
  2. # ScrapingAttempt model representing a job listing extraction attempt
  3. 1 class ScrapingAttempt < ApplicationRecord
  4. 1 include Transitionable
  5. 1 STATUSES = [
  6. :pending, # Initial state, queued
  7. :fetching, # Downloading HTML/API data
  8. :extracting, # Processing with LLM/parser
  9. :completed, # Successfully extracted
  10. :failed, # Failed, will retry
  11. :retrying, # In retry queue
  12. :dead_letter, # Exhausted retries, needs admin
  13. :manual # Admin manually fixed
  14. ].freeze
  15. 1 EXTRACTION_METHODS = [ :api, :ai, :html ].freeze
  16. 1 belongs_to :job_listing
  17. 1 has_one :scraped_job_listing_data, dependent: :nullify
  18. 1 has_many :llm_api_logs, class_name: "Ai::LlmApiLog", as: :loggable, dependent: :destroy
  19. 1 has_many :scraping_events, dependent: :destroy
  20. 1 has_many :html_scraping_logs, dependent: :destroy
  21. # Define enum for status to map integer values to state names
  22. 1 enum :status, {
  23. pending: 0,
  24. fetching: 1,
  25. extracting: 2,
  26. completed: 3,
  27. failed: 4,
  28. retrying: 5,
  29. dead_letter: 6,
  30. manual: 7
  31. }, default: :pending
  32. 1 validates :url, presence: true
  33. 1 validates :domain, presence: true
  34. 1 validates :extraction_method, inclusion: { in: EXTRACTION_METHODS.map(&:to_s) }, allow_nil: true
  35. 1 scope :recent, -> { order(created_at: :desc) }
  36. 1 scope :by_domain, ->(domain) { where(domain: domain) }
  37. 1 scope :by_status, ->(status) { where(status: status) }
  38. 1 scope :needs_review, -> { where(status: [ :dead_letter, :failed ]) }
  39. 1 scope :recent_period, ->(days = 7) { where("created_at > ?", days.days.ago) }
  40. # Status state machine
  41. 1 aasm column: :status, enum: true, with_klass: BaseAasm do
  42. 1 requires_guards!
  43. 1 log_transitions!
  44. 1 state :pending, initial: true
  45. 1 state :fetching
  46. 1 state :extracting
  47. 1 state :completed
  48. 1 state :failed
  49. 1 state :retrying
  50. 1 state :dead_letter
  51. 1 state :manual
  52. 1 event :start_fetch do
  53. 1 transitions from: [ :pending, :retrying ], to: :fetching
  54. end
  55. 1 event :start_extract do
  56. 1 transitions from: [ :fetching, :retrying ], to: :extracting
  57. end
  58. 1 event :mark_completed do
  59. 1 transitions from: :extracting, to: :completed
  60. end
  61. 1 event :mark_failed do
  62. 1 transitions from: [ :fetching, :extracting, :retrying ], to: :failed
  63. end
  64. 1 event :retry_attempt do
  65. 1 transitions from: :failed, to: :retrying
  66. end
  67. 1 event :send_to_dlq do
  68. 1 transitions from: :failed, to: :dead_letter
  69. end
  70. 1 event :mark_manual do
  71. 1 transitions from: [ :dead_letter, :failed ], to: :manual
  72. end
  73. end
  74. # Returns badge color for status
  75. # @return [String] Color name for badge
  76. 1 def status_badge_color
  77. when: 0 case status.to_sym
  78. when: 0 when :completed then "success"
  79. when: 0 when :pending, :fetching, :extracting, :retrying then "info"
  80. when: 0 when :failed then "danger"
  81. when: 0 when :dead_letter then "warning"
  82. else: 0 when :manual then "neutral"
  83. else "neutral"
  84. end
  85. end
  86. # Checks if this attempt needs admin review
  87. # @return [Boolean] True if needs review
  88. 1 def needs_review?
  89. dead_letter? || (failed? && retry_count >= 3)
  90. end
  91. # Checks if HTML fetch step failed
  92. # @return [Boolean] True if HTML fetch failed
  93. 1 def html_fetch_failed?
  94. failed_step == "html_fetch"
  95. end
  96. # Checks if API extraction step failed
  97. # @return [Boolean] True if API extraction failed
  98. 1 def api_extraction_failed?
  99. failed_step == "api_extraction"
  100. end
  101. # Checks if AI extraction step failed
  102. # @return [Boolean] True if AI extraction failed
  103. 1 def ai_extraction_failed?
  104. failed_step == "ai_extraction"
  105. end
  106. # Returns cached HTML data if available
  107. # @return [ScrapedJobListingData, nil] Cached HTML data or nil
  108. 1 def cached_html_data
  109. scraped_job_listing_data || ScrapedJobListingData.find_valid_for_url(url, job_listing: job_listing)
  110. end
  111. # Convenience accessor for the most recent HTML scraping log
  112. #
  113. # @return [HtmlScrapingLog, nil]
  114. 1 def latest_html_scraping_log
  115. html_scraping_logs.order(created_at: :desc).first
  116. end
  117. # Returns formatted duration
  118. # @return [String, nil] Formatted duration
  119. 1 def formatted_duration
  120. then: 0 else: 0 return nil if duration_seconds.nil?
  121. then: 0 if duration_seconds < 1
  122. else: 0 "#{(duration_seconds * 1000).round}ms"
  123. then: 0 elsif duration_seconds < 60
  124. "#{duration_seconds.round(2)}s"
  125. else: 0 else
  126. minutes = (duration_seconds / 60).floor
  127. seconds = (duration_seconds % 60).round
  128. "#{minutes}m #{seconds}s"
  129. end
  130. end
  131. # Returns success rate for this domain
  132. # @return [Float] Success rate as percentage
  133. 1 def self.success_rate_for_domain(domain, days = 7)
  134. attempts = by_domain(domain).recent_period(days)
  135. then: 0 else: 0 return 0.0 if attempts.count.zero?
  136. completed = attempts.where(status: :completed).count
  137. (completed.to_f / attempts.count * 100).round(1)
  138. end
  139. end

app/models/scraping_event.rb

0.0% lines covered

100.0% branches covered

137 relevant lines. 0 lines covered and 137 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # ScrapingEvent model for tracking individual steps in the scraping pipeline
  3. #
  4. # Records each step of the extraction process (fetch, parse, extract) with
  5. # timing, payloads, and status for complete observability.
  6. #
  7. # @example
  8. # event = ScrapingEvent.create!(
  9. # scraping_attempt: attempt,
  10. # event_type: :html_fetch,
  11. # step_order: 1,
  12. # status: :success,
  13. # duration_ms: 1500
  14. # )
  15. class ScrapingEvent < ApplicationRecord
  16. EVENT_TYPES = [
  17. :permission_check, # Robots.txt / rate limit check
  18. :job_board_detection, # Job board/ATS detection from URL
  19. :html_fetch, # Fetching HTML content
  20. :embedded_job_board_fetch, # Fetch embedded job board HTML (e.g., Greenhouse embeds)
  21. :js_heavy_detected, # Heuristic indicating JS-rendered content
  22. :rendered_html_fetch, # Selenium/Headless rendered HTML fetch
  23. :limited_source_handling, # Handling limited extraction sources (LinkedIn, etc.)
  24. :nokogiri_scrape, # Preliminary HTML parsing
  25. :selectors_extraction, # Selectors-first extraction (job boards)
  26. :api_extraction, # API-based extraction (Greenhouse, Lever)
  27. :ai_extraction, # LLM-based extraction
  28. :data_update, # Updating job listing with extracted data
  29. :completion, # Successful completion
  30. :failure # Pipeline failure
  31. ].freeze
  32. STATUSES = [
  33. :started, # Step has begun
  34. :success, # Step completed successfully
  35. :failed, # Step failed
  36. :skipped # Step was skipped
  37. ].freeze
  38. # Associations
  39. belongs_to :scraping_attempt
  40. belongs_to :job_listing, optional: true
  41. # Enums
  42. enum :event_type, {
  43. permission_check: "permission_check",
  44. job_board_detection: "job_board_detection",
  45. html_fetch: "html_fetch",
  46. embedded_job_board_fetch: "embedded_job_board_fetch",
  47. js_heavy_detected: "js_heavy_detected",
  48. rendered_html_fetch: "rendered_html_fetch",
  49. limited_source_handling: "limited_source_handling",
  50. nokogiri_scrape: "nokogiri_scrape",
  51. selectors_extraction: "selectors_extraction",
  52. api_extraction: "api_extraction",
  53. ai_extraction: "ai_extraction",
  54. data_update: "data_update",
  55. completion: "completion",
  56. failure: "failure"
  57. }
  58. enum :status, {
  59. started: 0,
  60. success: 1,
  61. failed: 2,
  62. skipped: 3
  63. }, default: :started
  64. # Validations
  65. validates :event_type, presence: true
  66. # Scopes
  67. scope :for_attempt, ->(attempt_id) { where(scraping_attempt_id: attempt_id) }
  68. scope :by_type, ->(type) { where(event_type: type) }
  69. scope :successful, -> { where(status: :success) }
  70. scope :failed, -> { where(status: :failed) }
  71. scope :in_order, -> { order(step_order: :asc, created_at: :asc) }
  72. scope :recent, -> { order(created_at: :desc) }
  73. # Returns formatted duration string
  74. #
  75. # @return [String] Formatted duration (e.g., "1.5s", "250ms")
  76. def formatted_duration
  77. return "N/A" if duration_ms.nil?
  78. if duration_ms < 1000
  79. "#{duration_ms}ms"
  80. else
  81. "#{(duration_ms / 1000.0).round(2)}s"
  82. end
  83. end
  84. # Returns a summary of the input payload
  85. #
  86. # @param [Integer] max_length Maximum length of summary
  87. # @return [String] Summary of input
  88. def input_summary(max_length = 100)
  89. return "No input" if input_payload.blank?
  90. summarize_payload(input_payload, max_length)
  91. end
  92. # Returns a summary of the output payload
  93. #
  94. # @param [Integer] max_length Maximum length of summary
  95. # @return [String] Summary of output
  96. def output_summary(max_length = 100)
  97. return "No output" if output_payload.blank?
  98. summarize_payload(output_payload, max_length)
  99. end
  100. # Returns the event type display name
  101. #
  102. # @return [String] Human-readable event type
  103. def event_type_display
  104. case event_type&.to_sym
  105. when :permission_check then "Permission Check"
  106. when :job_board_detection then "Job Board Detection"
  107. when :html_fetch then "HTML Fetch"
  108. when :embedded_job_board_fetch then "Embedded Job Board Fetch"
  109. when :js_heavy_detected then "JS-Heavy Detected"
  110. when :rendered_html_fetch then "Rendered HTML Fetch"
  111. when :limited_source_handling then "Limited Source Handling"
  112. when :nokogiri_scrape then "HTML Parse"
  113. when :selectors_extraction then "Selectors Extraction"
  114. when :api_extraction then "API Extraction"
  115. when :ai_extraction then "AI Extraction"
  116. when :data_update then "Data Update"
  117. when :completion then "Completion"
  118. when :failure then "Failure"
  119. else event_type&.titleize || "Unknown"
  120. end
  121. end
  122. # Returns icon name for the event type
  123. #
  124. # @return [String] Icon identifier
  125. def event_icon
  126. case event_type&.to_sym
  127. when :permission_check then "shield-check"
  128. when :job_board_detection then "tag"
  129. when :html_fetch then "cloud-download"
  130. when :embedded_job_board_fetch then "link"
  131. when :js_heavy_detected then "sparkles"
  132. when :rendered_html_fetch then "globe-alt"
  133. when :limited_source_handling then "exclamation-triangle"
  134. when :nokogiri_scrape then "code"
  135. when :selectors_extraction then "adjustments-horizontal"
  136. when :api_extraction then "server"
  137. when :ai_extraction then "cpu"
  138. when :data_update then "database"
  139. when :completion then "check-circle"
  140. when :failure then "x-circle"
  141. else "circle"
  142. end
  143. end
  144. # Returns status badge color
  145. #
  146. # @return [String] Color class name
  147. def status_badge_color
  148. case status&.to_sym
  149. when :success then "success"
  150. when :failed then "danger"
  151. when :skipped then "neutral"
  152. when :started then "info"
  153. else "neutral"
  154. end
  155. end
  156. # Checks if this event has error details
  157. #
  158. # @return [Boolean] True if has error
  159. def has_error?
  160. error_message.present? || error_type.present?
  161. end
  162. # Returns extracted fields from output payload
  163. #
  164. # @return [Array<String>] List of field names
  165. def extracted_fields
  166. return [] unless output_payload.is_a?(Hash)
  167. output_payload["extracted_fields"] || output_payload.keys.select do |k|
  168. !%w[error confidence raw_response].include?(k)
  169. end
  170. end
  171. private
  172. # Summarizes a payload hash for display
  173. #
  174. # @param [Hash] payload The payload to summarize
  175. # @param [Integer] max_length Maximum length
  176. # @return [String] Summary
  177. def summarize_payload(payload, max_length)
  178. return payload.to_s.truncate(max_length) unless payload.is_a?(Hash)
  179. keys = payload.keys.first(5)
  180. summary = keys.map { |k| "#{k}: #{payload[k].to_s.truncate(20)}" }.join(", ")
  181. if payload.keys.length > 5
  182. summary += " (+#{payload.keys.length - 5} more)"
  183. end
  184. summary.truncate(max_length)
  185. end
  186. end

app/models/session.rb

0.0% lines covered

100.0% branches covered

3 relevant lines. 0 lines covered and 3 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Session < ApplicationRecord
  2. belongs_to :user
  3. end

app/models/setting.rb

55.77% lines covered

12.5% branches covered

52 relevant lines. 29 lines covered and 23 lines missed.
16 total branches, 2 branches covered and 14 branches missed.
    
  1. 1 class Setting < ApplicationRecord
  2. AVAILABLE_SETTINGS = %w[
  3. 1 user_sign_up_enabled
  4. user_login_enabled
  5. user_email_verification_enabled
  6. username_password_login_enabled
  7. magic_link_login_enabled
  8. oauth_login_enabled
  9. oauth_registration_enabled
  10. google_login_enabled
  11. google_registration_enabled
  12. analytics_enabled
  13. mixpanel_enabled
  14. sentry_enabled
  15. bugsnag_enabled
  16. api_population_enabled
  17. ashby_enabled
  18. greenhouse_enabled
  19. lever_enabled
  20. linkedin_enabled
  21. indeed_enabled
  22. glassdoor_enabled
  23. ziprecruiter_enabled
  24. careerbuilder_enabled
  25. monster_enabled
  26. careerjet_enabled
  27. js_rendering_enabled
  28. turnstile_enabled
  29. helicone_enabled
  30. signals_decision_shadow_enabled
  31. signals_decision_execution_enabled
  32. signals_email_facts_extraction_enabled
  33. signals_decision_opportunity_creation_enabled
  34. ]
  35. 1 CACHE_KEY = "settings"
  36. 1 CACHE_TTL = ENV.fetch("CACHE_TTL", 15.seconds).to_i
  37. 1 SKIP_MUTEX = ENV.fetch("SETTINGS_SKIP_MUTEX", false).to_s == "true"
  38. 1 MUTEX = Mutex.new
  39. 1 validates :name, presence: true, uniqueness: { case_sensitive: true }, format: { with: /\A[a-zA-Z0-9_]+\z/, message: "can only contain letters, numbers, and underscores" }
  40. # after_save :purge_table_key
  41. 1 after_commit :purge_cache
  42. 1 AVAILABLE_SETTINGS.each do |setting|
  43. 31 define_singleton_method(:"#{setting}?") do
  44. (cached_settings || {})[setting.to_s] == true
  45. end
  46. end
  47. 1 def purge_cache
  48. 66 self.class.purge_cache
  49. end
  50. 1 class << self
  51. 1 attr_accessor :disabled_cached_settings
  52. 1 def toggle(name)
  53. else: 0 then: 0 raise "Setting unknown: #{name}" unless AVAILABLE_SETTINGS.include?(name)
  54. setting = where(name: name).first_or_create
  55. setting.value = !setting.value
  56. setting.save!
  57. setting.value
  58. end
  59. 1 def set(name:, value:)
  60. 33 else: 33 then: 0 raise "Setting unknown: #{name}" unless AVAILABLE_SETTINGS.include?(name)
  61. 33 setting = where(name: name).first_or_create
  62. 33 setting.value = value
  63. 33 setting.save!
  64. 33 setting.value
  65. end
  66. 1 def enable!(name)
  67. set(name: name.to_s, value: true)
  68. end
  69. 1 def disable!(name)
  70. set(name: name, value: false)
  71. end
  72. 1 def purge_cache
  73. 66 then: 0 else: 66 remove_instance_variable(:@cached_settings) if defined?(@cached_settings)
  74. 66 Rails.cache.delete("#{CACHE_KEY}")
  75. end
  76. 1 def cached_settings
  77. synchronize do
  78. else: 0 then: 0 return @cached_settings unless expired?
  79. value = Rails.cache.fetch(CACHE_KEY) do
  80. settings = Setting.all
  81. settings.each_with_object({}) do |setting, hash|
  82. hash[setting.name] = setting.value
  83. end
  84. end
  85. @cached_settings = value || {} # Ensure we always return a hash, never nil
  86. @cached_settings_expires_at = Time.current.to_i + CACHE_TTL
  87. @cached_settings
  88. end
  89. end
  90. 1 def expired?
  91. else: 0 then: 0 return true unless defined?(@cached_settings_expires_at)
  92. then: 0 else: 0 return true if @cached_settings_expires_at.nil?
  93. then: 0 else: 0 return true if @cached_settings_expires_at < Time.current.to_i
  94. @cached_settings_expires_at <= Time.current.to_i + CACHE_TTL
  95. end
  96. 1 def synchronize(&block)
  97. then: 0 else: 0 return yield if SKIP_MUTEX
  98. MUTEX.synchronize(&block)
  99. end
  100. end
  101. end

app/models/signals/email_pipeline_event.rb

0.0% lines covered

100.0% branches covered

48 relevant lines. 0 lines covered and 48 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Represents a single step/event within an EmailPipelineRun.
  4. class EmailPipelineEvent < ApplicationRecord
  5. EVENT_TYPES = [
  6. :synced_email_upsert,
  7. :email_classification,
  8. :company_detection,
  9. :application_match,
  10. :signal_extraction_enqueued,
  11. :legacy_signal_extraction,
  12. :email_facts_extraction,
  13. :decision_input_build,
  14. :decision_plan_build,
  15. :decision_plan_schema_validate,
  16. :decision_plan_semantic_validate,
  17. :execution_dispatch,
  18. :execute_set_pipeline_stage,
  19. :execute_set_application_status,
  20. :execute_create_round,
  21. :execute_update_round,
  22. :execute_set_round_result,
  23. :execute_create_interview_feedback,
  24. :execute_create_company_feedback,
  25. :execute_create_opportunity,
  26. :execute_upsert_job_listing_from_url,
  27. :execute_attach_job_listing_to_opportunity,
  28. :execute_enqueue_scrape_job_listing,
  29. :legacy_orchestrator
  30. ].freeze
  31. STATUSES = %i[started success failed skipped].freeze
  32. belongs_to :run, class_name: "Signals::EmailPipelineRun", inverse_of: :events
  33. belongs_to :synced_email
  34. belongs_to :interview_application, optional: true
  35. enum :event_type, EVENT_TYPES.index_with(&:to_s)
  36. enum :status, {
  37. started: 0,
  38. success: 1,
  39. failed: 2,
  40. skipped: 3
  41. }, default: :started
  42. validates :event_type, presence: true
  43. validates :status, presence: true
  44. validates :step_order, presence: true
  45. scope :recent, -> { order(created_at: :desc) }
  46. scope :in_order, -> { order(step_order: :asc, created_at: :asc) }
  47. scope :by_type, ->(type) { where(event_type: type) }
  48. scope :by_status, ->(status) { where(status: status) }
  49. end
  50. end

app/models/signals/email_pipeline_run.rb

0.0% lines covered

100.0% branches covered

27 relevant lines. 0 lines covered and 27 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Represents a single end-to-end processing run for a SyncedEmail.
  4. #
  5. # Mirrors the ScrapingAttempt/ScrapingEvent pattern, but for the email → signals pipeline.
  6. class EmailPipelineRun < ApplicationRecord
  7. STATUSES = %i[started success failed].freeze
  8. belongs_to :synced_email
  9. belongs_to :user
  10. belongs_to :connected_account
  11. has_many :events,
  12. class_name: "Signals::EmailPipelineEvent",
  13. foreign_key: :run_id,
  14. inverse_of: :run,
  15. dependent: :destroy
  16. enum :status, {
  17. started: 0,
  18. success: 1,
  19. failed: 2
  20. }, default: :started
  21. validates :status, presence: true
  22. validates :trigger, presence: true
  23. validates :mode, presence: true
  24. validates :started_at, presence: true
  25. scope :recent, -> { order(created_at: :desc) }
  26. scope :by_status, ->(status) { where(status: status) }
  27. def next_step_order
  28. events.maximum(:step_order).to_i + 1
  29. end
  30. end
  31. end

app/models/skill_tag.rb

0.0% lines covered

100.0% branches covered

96 relevant lines. 0 lines covered and 96 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # SkillTag model representing skills tracked across interviews and resumes
  3. class SkillTag < ApplicationRecord
  4. include Disableable
  5. extend FriendlyId
  6. friendly_id :name, use: [ :slugged, :finders ]
  7. # def should_generate_new_friendly_id?
  8. # name_changed?
  9. # end
  10. #
  11. # Skill name aliases for normalization (maps variations to canonical names)
  12. SKILL_ALIASES = {
  13. "postgres" => "Postgresql",
  14. "postgre" => "Postgresql",
  15. "psql" => "Postgresql",
  16. "js" => "Javascript",
  17. "ts" => "Typescript",
  18. "react.js" => "React",
  19. "reactjs" => "React",
  20. "node.js" => "Node",
  21. "nodejs" => "Node",
  22. "vue.js" => "Vue",
  23. "vuejs" => "Vue",
  24. "k8s" => "Kubernetes",
  25. "aws" => "Aws",
  26. "gcp" => "Google Cloud",
  27. "ror" => "Ruby On Rails"
  28. }.freeze
  29. # Interview associations
  30. has_many :application_skill_tags, dependent: :destroy
  31. has_many :interview_applications, through: :application_skill_tags
  32. belongs_to :category, optional: true
  33. # Resume associations
  34. has_many :resume_skills, dependent: :destroy
  35. has_many :user_resumes, through: :resume_skills
  36. has_many :user_skills, dependent: :destroy
  37. has_many :users, through: :user_skills
  38. validates :name, presence: true, uniqueness: true
  39. normalizes :name, with: ->(name) { normalize_skill_name(name) }
  40. scope :by_category, ->(category_id) { where(category_id: category_id) }
  41. scope :alphabetical, -> { order(:name) }
  42. scope :popular, -> { joins("INNER JOIN interview_skill_tags ON interview_skill_tags.skill_tag_id = skill_tags.id").group("skill_tags.id").order("COUNT(interview_skill_tags.id) DESC") }
  43. scope :from_resumes, -> { joins(:resume_skills).distinct }
  44. def category_name
  45. category&.name
  46. end
  47. def legacy_category_name
  48. respond_to?(:legacy_category) ? legacy_category : nil
  49. end
  50. # Returns the count of interview applications associated with this skill
  51. # @return [Integer] Interview application count
  52. def interview_application_count
  53. interview_applications.count
  54. end
  55. # Finds or creates a skill tag by name (with alias normalization)
  56. # @param name [String] Name of the skill
  57. # @return [SkillTag] The skill tag instance
  58. def self.find_or_create_by_name(name)
  59. normalized = normalize_skill_name(name)
  60. find_or_create_by(name: normalized)
  61. end
  62. # Normalizes a skill name, handling aliases
  63. # @param name [String] Raw skill name
  64. # @return [String] Normalized skill name
  65. def self.normalize_skill_name(name)
  66. cleaned = name.to_s.strip.downcase
  67. canonical = SKILL_ALIASES[cleaned] || cleaned.titleize
  68. canonical
  69. end
  70. # Merges duplicate skills into one
  71. # @param source_skill [SkillTag] The skill to merge from
  72. # @param target_skill [SkillTag] The skill to merge into
  73. # Merges a source skill tag into a target skill tag
  74. #
  75. # @param source_skill [SkillTag] The skill to be merged (will be deleted)
  76. # @param target_skill [SkillTag] The skill to merge into
  77. # @return [Hash] Result hash with :success, :message/:error keys
  78. def self.merge_skills(source_skill, target_skill)
  79. if source_skill == target_skill
  80. return { success: false, error: "Cannot merge a skill tag into itself." }
  81. end
  82. if source_skill.nil? || target_skill.nil?
  83. return { success: false, error: "Source or target skill tag not found." }
  84. end
  85. stats = { resume_skills: 0, user_skills: 0, application_skills: 0 }
  86. transaction do
  87. # Handle duplicate resume_skills by removing them first
  88. # Note: resume_skills uses user_resume_id (not resume_id)
  89. duplicate_resume_ids = ResumeSkill.where(skill_tag: source_skill)
  90. .joins("INNER JOIN resume_skills rs2 ON resume_skills.user_resume_id = rs2.user_resume_id")
  91. .where("rs2.skill_tag_id = ?", target_skill.id)
  92. .pluck(:id)
  93. ResumeSkill.where(id: duplicate_resume_ids).delete_all
  94. # Update remaining resume_skills
  95. stats[:resume_skills] = ResumeSkill.where(skill_tag: source_skill).update_all(skill_tag_id: target_skill.id)
  96. # Handle duplicate user_skills by removing them first
  97. duplicate_user_ids = UserSkill.where(skill_tag: source_skill)
  98. .joins("INNER JOIN user_skills us2 ON user_skills.user_id = us2.user_id")
  99. .where("us2.skill_tag_id = ?", target_skill.id)
  100. .pluck(:id)
  101. UserSkill.where(id: duplicate_user_ids).delete_all
  102. # Update remaining user_skills
  103. stats[:user_skills] = UserSkill.where(skill_tag: source_skill).update_all(skill_tag_id: target_skill.id)
  104. # Handle duplicate interview_skill_tags by removing them first
  105. # Note: ApplicationSkillTag maps to interview_skill_tags table with interview_id column
  106. duplicate_app_ids = ApplicationSkillTag.where(skill_tag: source_skill)
  107. .joins("INNER JOIN interview_skill_tags ist2 ON interview_skill_tags.interview_id = ist2.interview_id")
  108. .where("ist2.skill_tag_id = ?", target_skill.id)
  109. .pluck(:id)
  110. ApplicationSkillTag.where(id: duplicate_app_ids).delete_all
  111. # Update remaining interview_skill_tags
  112. stats[:application_skills] = ApplicationSkillTag.where(skill_tag: source_skill).update_all(skill_tag_id: target_skill.id)
  113. # Delete the source skill
  114. source_skill.destroy!
  115. end
  116. {
  117. success: true,
  118. message: "Transferred #{stats[:resume_skills]} resume skills, #{stats[:user_skills]} user skills, and #{stats[:application_skills]} application skills."
  119. }
  120. rescue ActiveRecord::RecordNotUnique => e
  121. Rails.logger.error("Merge failed due to duplicate key: #{e.message}")
  122. { success: false, error: "Merge failed: Some records already exist on the target skill. Please try again." }
  123. rescue ActiveRecord::RecordNotDestroyed => e
  124. Rails.logger.error("Merge failed - could not delete source: #{e.message}")
  125. { success: false, error: "Merge failed: Could not delete the source skill tag. #{e.record.errors.full_messages.join(', ')}" }
  126. rescue => e
  127. Rails.logger.error("Merge failed: #{e.class} - #{e.message}")
  128. { success: false, error: "Merge failed: #{e.message}" }
  129. end
  130. end

app/models/support_ticket.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # SupportTicket model for contact form submissions
  3. class SupportTicket < ApplicationRecord
  4. belongs_to :user, optional: true
  5. validates :name, presence: true
  6. validates :email, presence: true
  7. validates :subject, presence: true
  8. validates :message, presence: true
  9. validates :status, presence: true
  10. normalizes :email, with: ->(e) { e.strip.downcase }
  11. enum :status, {
  12. open: "open",
  13. in_progress: "in_progress",
  14. resolved: "resolved",
  15. closed: "closed"
  16. }
  17. scope :recent, -> { order(created_at: :desc) }
  18. scope :open_tickets, -> { where(status: [:open, :in_progress]) }
  19. scope :by_status, ->(status) { where(status: status) if status.present? }
  20. # Returns display name for the ticket
  21. # @return [String]
  22. def display_name
  23. "#{name} - #{subject}"
  24. end
  25. # Checks if ticket is from a registered user
  26. # @return [Boolean]
  27. def from_user?
  28. user.present?
  29. end
  30. # Returns a truncated message for display
  31. # @param length [Integer] Maximum length
  32. # @return [String]
  33. def message_preview(length = 100)
  34. return message if message.length <= length
  35. "#{message[0...length]}..."
  36. end
  37. end

app/models/synced_email.rb

0.0% lines covered

100.0% branches covered

464 relevant lines. 0 lines covered and 464 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # SyncedEmail model for tracking emails synced from Gmail
  3. # Links emails to interview applications and tracks processing status
  4. #
  5. # Includes AI-powered signal extraction to derive actionable intelligence
  6. # from email content (company info, recruiter details, job information).
  7. #
  8. # @example
  9. # email = SyncedEmail.create_from_gmail_message(user, account, message)
  10. # email.process!
  11. #
  12. class SyncedEmail < ApplicationRecord
  13. STATUSES = %i[pending processed ignored failed auto_ignored].freeze
  14. EMAIL_TYPES = %w[
  15. application_confirmation
  16. interview_invite
  17. interview_reminder
  18. round_feedback
  19. rejection
  20. offer
  21. follow_up
  22. thank_you
  23. scheduling
  24. assessment
  25. recruiter_outreach
  26. other
  27. ].freeze
  28. # Types that are interview-related
  29. INTERVIEW_TYPES = %w[
  30. application_confirmation
  31. interview_invite
  32. interview_reminder
  33. round_feedback
  34. rejection
  35. offer
  36. follow_up
  37. scheduling
  38. assessment
  39. ].freeze
  40. # Types that represent potential opportunities
  41. OPPORTUNITY_TYPES = %w[recruiter_outreach interview_invite follow_up].freeze
  42. # Extraction status values
  43. EXTRACTION_STATUSES = %w[pending processing completed failed skipped].freeze
  44. # Backend actions that require user decision (not automatic)
  45. # Note: match_application is handled by the dropdown in the detail panel
  46. SUGGESTED_ACTIONS = %w[
  47. start_application
  48. ].freeze
  49. # Safe CSS properties that can be preserved in email HTML
  50. # These are visual properties that don't pose security risks
  51. SAFE_STYLE_PROPERTIES = %w[
  52. text-align text-decoration color background-color background
  53. font-weight font-style font-size line-height font-family
  54. padding padding-top padding-bottom padding-left padding-right
  55. margin margin-top margin-bottom margin-left margin-right
  56. border border-radius border-color border-width border-style
  57. border-top border-bottom border-left border-right
  58. width max-width min-width height max-height min-height
  59. display vertical-align white-space word-wrap overflow
  60. table-layout border-collapse border-spacing
  61. list-style list-style-type
  62. ].freeze
  63. belongs_to :user
  64. belongs_to :connected_account
  65. belongs_to :interview_application, optional: true
  66. belongs_to :email_sender, optional: true
  67. has_one :opportunity, dependent: :nullify
  68. has_many :email_pipeline_runs,
  69. class_name: "Signals::EmailPipelineRun",
  70. dependent: :destroy
  71. # Status enum
  72. enum :status, STATUSES, default: :pending
  73. # Validations
  74. validates :gmail_id, presence: true, uniqueness: { scope: :user_id }
  75. validates :from_email, presence: true
  76. validates :email_type, inclusion: { in: EMAIL_TYPES }, allow_blank: true
  77. # Normalizations
  78. normalizes :from_email, with: ->(email) { email.strip.downcase }
  79. # Scopes
  80. scope :unmatched, -> { where(interview_application_id: nil) }
  81. scope :matched, -> { where.not(interview_application_id: nil) }
  82. scope :by_type, ->(type) { where(email_type: type) }
  83. scope :recent, -> { order(email_date: :desc) }
  84. scope :chronological, -> { order(email_date: :asc) }
  85. scope :by_thread, ->(thread_id) { where(thread_id: thread_id) }
  86. scope :needs_review, -> { pending.unmatched }
  87. scope :from_account, ->(account) { where(connected_account: account) }
  88. scope :for_application, ->(app) { where(interview_application: app) }
  89. scope :recruiter_outreach, -> { where(email_type: "recruiter_outreach") }
  90. # Relevance scopes for smart filtering
  91. scope :interview_related, -> {
  92. where(email_type: INTERVIEW_TYPES).or(matched)
  93. }
  94. scope :potential_opportunities, -> { where(email_type: OPPORTUNITY_TYPES) }
  95. scope :relevant, -> {
  96. visible.where(
  97. "email_type IN (?) OR email_type IN (?) OR interview_application_id IS NOT NULL",
  98. INTERVIEW_TYPES,
  99. OPPORTUNITY_TYPES
  100. )
  101. }
  102. scope :not_ignored, -> { where.not(status: :ignored) }
  103. scope :not_auto_ignored, -> { where.not(status: :auto_ignored) }
  104. scope :visible, -> { where.not(status: [ :ignored, :auto_ignored ]) }
  105. # Callbacks
  106. before_save :link_or_create_sender
  107. # Store accessors for metadata
  108. store_accessor :metadata, :to_email, :cc_emails, :reply_to, :importance
  109. # Store accessors for extracted signal intelligence
  110. # Company information
  111. store_accessor :extracted_data,
  112. :signal_company_name, :signal_company_website, :signal_company_careers_url, :signal_company_domain,
  113. # Recruiter information
  114. :signal_recruiter_name, :signal_recruiter_email, :signal_recruiter_title, :signal_recruiter_linkedin,
  115. # Job information
  116. :signal_job_title, :signal_job_department, :signal_job_location, :signal_job_url, :signal_job_salary_hint,
  117. # Action links (LLM-classified URLs with dynamic labels) and backend actions
  118. :signal_action_links, :signal_suggested_actions
  119. # Extraction scopes
  120. scope :extraction_pending, -> { where(extraction_status: "pending") }
  121. scope :extraction_completed, -> { where(extraction_status: "completed") }
  122. scope :extraction_failed, -> { where(extraction_status: "failed") }
  123. scope :needs_extraction, -> { where(extraction_status: [ "pending", "failed" ]) }
  124. scope :has_signals, -> { where.not(extracted_data: {}) }
  125. # Creates a SyncedEmail from a parsed Gmail message
  126. #
  127. # @param user [User] The user who owns this email
  128. # @param connected_account [ConnectedAccount] The Gmail account
  129. # @param message_data [Hash] Parsed email data from Gmail::SyncService
  130. # @return [SyncedEmail, nil]
  131. def self.create_from_gmail_message(user, connected_account, message_data)
  132. return nil if message_data.blank? || message_data[:id].blank?
  133. # Check if already synced
  134. existing = find_by(user: user, gmail_id: message_data[:id])
  135. return existing if existing
  136. create!(
  137. user: user,
  138. connected_account: connected_account,
  139. gmail_id: message_data[:id],
  140. thread_id: message_data[:thread_id],
  141. subject: message_data[:subject],
  142. from_email: extract_email(message_data[:from]),
  143. from_name: extract_name(message_data[:from]),
  144. email_date: message_data[:date],
  145. snippet: message_data[:snippet],
  146. body_preview: message_data[:body_preview],
  147. body_html: message_data[:body_html],
  148. labels: message_data[:labels] || [],
  149. status: :pending
  150. )
  151. rescue ActiveRecord::RecordNotUnique
  152. # Handle race condition - email already exists
  153. find_by(user: user, gmail_id: message_data[:id])
  154. rescue ActiveRecord::RecordInvalid => e
  155. Rails.logger.warn "Failed to create SyncedEmail: #{e.message}"
  156. nil
  157. end
  158. # Extracts email address from "Name <email>" format
  159. #
  160. # @param from_string [String] The from header value
  161. # @return [String]
  162. def self.extract_email(from_string)
  163. return "" if from_string.blank?
  164. match = from_string.match(/<([^>]+)>/)
  165. match ? match[1].strip.downcase : from_string.strip.downcase
  166. end
  167. # Extracts display name from "Name <email>" format
  168. #
  169. # @param from_string [String] The from header value
  170. # @return [String, nil]
  171. def self.extract_name(from_string)
  172. return nil if from_string.blank?
  173. match = from_string.match(/^([^<]+)</)
  174. match ? match[1].strip.gsub(/"/, "") : nil
  175. end
  176. # Marks this email as matched to an application
  177. #
  178. # @param application [InterviewApplication] The matched application
  179. # @return [Boolean]
  180. def match_to_application!(application)
  181. update!(
  182. interview_application: application,
  183. status: :processed
  184. )
  185. end
  186. # Marks this email as ignored (not interview-related)
  187. #
  188. # @return [Boolean]
  189. def ignore!
  190. update!(status: :ignored)
  191. end
  192. # Marks this email as processed (manual override).
  193. #
  194. # @return [Boolean]
  195. def mark_processed!
  196. update!(status: :processed)
  197. end
  198. # Marks this email as needing review (manual override).
  199. #
  200. # The "needs review" concept is derived (pending + unmatched), not a persisted status.
  201. # This action resets the email back to pending and clears any application match.
  202. #
  203. # @return [Boolean]
  204. def mark_needs_review!
  205. update!(status: :pending, interview_application: nil)
  206. end
  207. # Marks processing as failed
  208. #
  209. # @param reason [String] The failure reason
  210. # @return [Boolean]
  211. def mark_failed!(reason = nil)
  212. update!(
  213. status: :failed,
  214. metadata: metadata.merge("failure_reason" => reason)
  215. )
  216. end
  217. # Checks if this email is matched to an application
  218. #
  219. # @return [Boolean]
  220. def matched?
  221. interview_application_id.present?
  222. end
  223. # Returns a short display subject
  224. #
  225. # @param length [Integer] Maximum length
  226. # @return [String]
  227. def short_subject(length = 50)
  228. subject&.truncate(length) || "(No subject)"
  229. end
  230. # Returns the sender display (name or email)
  231. #
  232. # @return [String]
  233. def sender_display
  234. from_name.presence || from_email
  235. end
  236. # Returns the company associated with this email (via sender or application)
  237. #
  238. # @return [Company, nil]
  239. def company
  240. interview_application&.company || email_sender&.effective_company
  241. end
  242. # Returns CSS classes for email type badge
  243. #
  244. # @return [String]
  245. def type_badge_classes
  246. case email_type
  247. when "interview_invite", "scheduling"
  248. "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
  249. when "offer"
  250. "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
  251. when "rejection"
  252. "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
  253. when "application_confirmation"
  254. "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"
  255. when "assessment"
  256. "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
  257. when "recruiter_outreach"
  258. "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300"
  259. else
  260. "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
  261. end
  262. end
  263. # Returns icon name for email type
  264. #
  265. # @return [String]
  266. def type_icon
  267. case email_type
  268. when "interview_invite", "scheduling"
  269. "calendar"
  270. when "offer"
  271. "gift"
  272. when "rejection"
  273. "x-circle"
  274. when "application_confirmation"
  275. "check-circle"
  276. when "assessment"
  277. "clipboard-check"
  278. when "recruiter_outreach"
  279. "sparkles"
  280. when "follow_up", "thank_you"
  281. "mail"
  282. else
  283. "mail"
  284. end
  285. end
  286. # Checks if this email is a recruiter outreach
  287. #
  288. # @return [Boolean]
  289. def recruiter_outreach?
  290. email_type == "recruiter_outreach"
  291. end
  292. # Signal Extraction Methods
  293. # -------------------------
  294. # Checks if signal extraction has been completed
  295. #
  296. # @return [Boolean]
  297. def extraction_completed?
  298. extraction_status == "completed"
  299. end
  300. # Checks if this email has extracted signals
  301. #
  302. # @return [Boolean]
  303. def has_signals?
  304. extracted_data.present? && extracted_data.keys.any?
  305. end
  306. # Checks if this email has company information extracted
  307. #
  308. # @return [Boolean]
  309. def has_company_signal?
  310. signal_company_name.present?
  311. end
  312. # Checks if this email has recruiter information extracted
  313. #
  314. # @return [Boolean]
  315. def has_recruiter_signal?
  316. signal_recruiter_name.present? || signal_recruiter_email.present?
  317. end
  318. # Checks if this email has job information extracted
  319. #
  320. # @return [Boolean]
  321. def has_job_signal?
  322. signal_job_title.present? || signal_job_url.present?
  323. end
  324. # Checks if this email has action links extracted
  325. #
  326. # @return [Boolean]
  327. def has_action_links?
  328. signal_action_links.present? && signal_action_links.any?
  329. end
  330. # Returns the list of backend actions for this email
  331. #
  332. # @return [Array<String>]
  333. def suggested_actions
  334. signal_suggested_actions || []
  335. end
  336. # Returns action links sorted by priority (1=highest)
  337. # Each link has: url, action_label, priority
  338. #
  339. # @return [Array<Hash>]
  340. def action_links
  341. links = signal_action_links || []
  342. links.sort_by { |link| link["priority"] || 5 }
  343. end
  344. # Returns the highest priority action link (usually scheduling or apply)
  345. #
  346. # @return [Hash, nil]
  347. def primary_action_link
  348. action_links.first
  349. end
  350. # Checks if there's a scheduling-related action link
  351. #
  352. # @return [Boolean]
  353. def has_scheduling_link?
  354. action_links.any? do |link|
  355. label = link["action_label"].to_s.downcase
  356. label.include?("schedule") || label.include?("book") || label.include?("calendar")
  357. end
  358. end
  359. # Returns scheduling-related action links
  360. #
  361. # @return [Array<Hash>]
  362. def scheduling_links
  363. action_links.select do |link|
  364. label = link["action_label"].to_s.downcase
  365. label.include?("schedule") || label.include?("book") || label.include?("calendar")
  366. end
  367. end
  368. # Marks extraction as started
  369. #
  370. # @return [Boolean]
  371. def mark_extraction_processing!
  372. update!(extraction_status: "processing")
  373. end
  374. # Updates with extraction results
  375. #
  376. # @param data [Hash] Extracted data
  377. # @param confidence [Float] Confidence score (0.0-1.0)
  378. # @return [Boolean]
  379. def update_extraction!(data, confidence: nil)
  380. update!(
  381. extracted_data: data,
  382. extraction_status: "completed",
  383. extraction_confidence: confidence,
  384. extracted_at: Time.current
  385. )
  386. end
  387. # Marks extraction as failed
  388. #
  389. # @param reason [String] Failure reason
  390. # @return [Boolean]
  391. def mark_extraction_failed!(reason = nil)
  392. update!(
  393. extraction_status: "failed",
  394. extracted_data: extracted_data.merge("extraction_error" => reason)
  395. )
  396. end
  397. # Marks extraction as skipped (not worth extracting)
  398. #
  399. # @return [Boolean]
  400. def mark_extraction_skipped!
  401. update!(extraction_status: "skipped")
  402. end
  403. # Returns all emails in this conversation thread
  404. # Includes this email, ordered chronologically (oldest first)
  405. #
  406. # @return [ActiveRecord::Relation<SyncedEmail>]
  407. def thread_emails
  408. return SyncedEmail.where(id: id) if thread_id.blank?
  409. SyncedEmail.where(user: user, thread_id: thread_id).chronological
  410. end
  411. # Returns count of emails in this thread
  412. #
  413. # @return [Integer]
  414. def thread_count
  415. return 1 if thread_id.blank?
  416. SyncedEmail.where(user: user, thread_id: thread_id).count
  417. end
  418. # Checks if this email is part of a multi-email thread
  419. #
  420. # @return [Boolean]
  421. def has_thread?
  422. thread_count > 1
  423. end
  424. # Returns the first email in this thread (conversation starter)
  425. #
  426. # @return [SyncedEmail]
  427. def thread_root
  428. return self if thread_id.blank?
  429. SyncedEmail.where(user: user, thread_id: thread_id).chronological.first || self
  430. end
  431. # Returns the most recent email in this thread
  432. #
  433. # @return [SyncedEmail]
  434. def thread_latest
  435. return self if thread_id.blank?
  436. SyncedEmail.where(user: user, thread_id: thread_id).recent.first || self
  437. end
  438. # Returns a clean subject without Re:/Fwd: prefixes
  439. #
  440. # @return [String]
  441. def clean_subject
  442. return "(No subject)" if subject.blank?
  443. subject.gsub(/^(re:|fwd?:)\s*/i, "").strip.presence || "(No subject)"
  444. end
  445. # Checks if this email has HTML content
  446. #
  447. # @return [Boolean]
  448. def has_html_body?
  449. body_html.present?
  450. end
  451. # Returns the best available body content for display
  452. # Prefers plain text for simple display, but HTML is available for rich rendering
  453. #
  454. # @return [String]
  455. def display_body
  456. body_preview.presence || snippet.presence || ""
  457. end
  458. # Returns sanitized HTML body safe for rendering
  459. # Removes potentially dangerous tags/attributes while preserving formatting
  460. #
  461. # Security measures:
  462. # - Allowlist of safe HTML tags only
  463. # - Style attributes sanitized to only allow safe CSS properties
  464. # - URL scheme validation for href/src (blocks javascript:, data:, etc.)
  465. #
  466. # @return [String, nil]
  467. def safe_html_body
  468. return nil unless body_html.present?
  469. # First pass: Remove unwanted elements and extract/sanitize styles
  470. # We extract styles because Rails sanitizer strips url() values
  471. cleaned_html, style_map = strip_unwanted_html_with_styles(body_html)
  472. # Second pass: Rails sanitizer with safe list of tags
  473. # Exclude style attribute - Rails strips url() values
  474. # Include data-se-style-id for style re-injection
  475. # width/height preserved for proper image sizing
  476. sanitized = ActionController::Base.helpers.sanitize(
  477. cleaned_html,
  478. tags: %w[p br div span a ul ol li strong b em i u h1 h2 h3 h4 h5 h6 blockquote pre code table tr td th thead tbody hr img],
  479. attributes: %w[href src alt title class target width height align valign data-se-style-id]
  480. )
  481. # Third pass: Re-inject sanitized styles that were preserved
  482. sanitized = reinject_styles(sanitized, style_map)
  483. # Fourth pass: Validate URL schemes in href and src attributes
  484. # Only allow http, https, and mailto schemes
  485. sanitize_url_schemes(sanitized)
  486. end
  487. private
  488. # Removes unwanted HTML elements and extracts sanitized styles
  489. # Returns both cleaned HTML and a map of element IDs to their safe styles
  490. #
  491. # Rails sanitizer strips url() from styles, so we extract styles first,
  492. # let Rails sanitize the rest, then re-inject the safe styles after.
  493. #
  494. # @param html [String] Raw HTML string
  495. # @return [Array<String, Hash>] [cleaned_html, style_map]
  496. def strip_unwanted_html_with_styles(html)
  497. fragment = Nokogiri::HTML::DocumentFragment.parse(html)
  498. style_map = {}
  499. style_counter = 0
  500. # Remove non-content elements entirely
  501. fragment.css("style, script, noscript, head, title, meta, link").remove
  502. # Remove elements hidden via inline styles, extract safe styles from others
  503. fragment.css("*[style]").each do |node|
  504. style = node["style"].to_s.downcase
  505. if style.include?("display:none") || style.include?("display: none") ||
  506. style.include?("visibility:hidden") || style.include?("visibility: hidden") ||
  507. style.include?("font-size:0") || style.include?("font-size: 0") ||
  508. style.include?("line-height:0") || style.include?("line-height: 0") ||
  509. style.include?("max-height:0") || style.include?("max-height: 0")
  510. node.remove
  511. next
  512. end
  513. # Sanitize style attribute to only allow safe CSS properties
  514. safe_style = extract_safe_styles(node["style"])
  515. if safe_style.present?
  516. # Store the safe style with a unique marker
  517. style_id = "se-style-#{style_counter += 1}"
  518. style_map[style_id] = safe_style
  519. # Add a data attribute that Rails won't strip
  520. node["data-se-style-id"] = style_id
  521. end
  522. # Remove the style attribute so Rails doesn't mangle it
  523. node.remove_attribute("style")
  524. end
  525. # Remove tracking pixels and tiny images (1x1, 0x0, etc.)
  526. fragment.css("img").each do |img|
  527. width = img["width"].to_s.gsub(/\D/, "").to_i
  528. height = img["height"].to_s.gsub(/\D/, "").to_i
  529. # Remove if explicitly tiny (tracking pixels)
  530. if (width > 0 && width <= 3) || (height > 0 && height <= 3)
  531. img.remove
  532. end
  533. end
  534. # Remove HTML comments
  535. fragment.traverse do |node|
  536. node.remove if node.comment?
  537. end
  538. # Remove empty elements that create whitespace (multiple passes)
  539. 2.times do
  540. fragment.css("div, span, p, td, tr, table").each do |node|
  541. # Check if element has no meaningful content
  542. text_content = node.text.to_s.strip
  543. has_images = node.css("img").any?
  544. has_links = node.css("a").any? { |a| a.text.to_s.strip.present? }
  545. # Remove if empty (no text, no images, no meaningful links)
  546. if text_content.empty? && !has_images && !has_links
  547. # Keep if it has child elements with content
  548. has_content_children = node.children.any? do |child|
  549. child.element? && (child.text.to_s.strip.present? || child.css("img").any?)
  550. end
  551. node.remove unless has_content_children
  552. end
  553. end
  554. end
  555. # Remove leading br tags
  556. while (first = fragment.children.first) && first.name == "br"
  557. first.remove
  558. end
  559. # Detect and mark signature sections (images after text content ends)
  560. mark_signature_images(fragment)
  561. [ fragment.to_html, style_map ]
  562. rescue StandardError
  563. [ html, {} ]
  564. end
  565. # Marks images that appear to be in email signatures
  566. # Signatures typically appear after the main text content
  567. #
  568. # @param fragment [Nokogiri::HTML::DocumentFragment]
  569. def mark_signature_images(fragment)
  570. all_images = fragment.css("img").to_a
  571. return if all_images.empty?
  572. # Find signature section by looking for common patterns
  573. signature_indicators = [
  574. "Best regards", "Kind regards", "Regards", "Thanks", "Thank you",
  575. "Cheers", "Best,", "Sincerely", "Warmly", "Yours",
  576. "Get Outlook", "Sent from", "—", "--"
  577. ]
  578. # Count images and their positions
  579. total_text_length = fragment.text.to_s.length
  580. all_images.each_with_index do |img, idx|
  581. src = img["src"].to_s.downcase
  582. alt = img["alt"].to_s.downcase
  583. # Calculate approximate position of this image in the document
  584. text_before_img = ""
  585. img.traverse { |n| break if n == img; text_before_img += n.text.to_s if n.text? }
  586. position_ratio = total_text_length > 0 ? text_before_img.length.to_f / total_text_length : 1.0
  587. # Check if it's likely a social media icon by URL pattern
  588. is_social_url = src.match?(/linkedin|facebook|twitter|instagram|youtube|social|fbcdn|licdn|static\.licdn/)
  589. # Check if it's likely a social media icon by alt text
  590. is_social_alt = alt.match?(/linkedin|facebook|twitter|instagram|youtube|follow|connect/)
  591. # Check if image appears in latter half of email (signature area)
  592. is_in_signature_area = position_ratio > 0.5
  593. # Check if multiple images appear consecutively (common for social icon rows)
  594. has_sibling_images = idx > 0 || (idx < all_images.length - 1)
  595. # Mark as social icon if matches patterns
  596. if is_social_url || is_social_alt || (is_in_signature_area && has_sibling_images && idx > 0)
  597. existing_class = img["class"].to_s
  598. img["class"] = "#{existing_class} email-social-icon".strip
  599. end
  600. end
  601. end
  602. # Validates and sanitizes URL schemes in href and src attributes
  603. # Blocks dangerous schemes like javascript:, data:, vbscript:, etc.
  604. #
  605. # @param html [String] The HTML to sanitize
  606. # @return [String] HTML with dangerous URLs removed
  607. def sanitize_url_schemes(html)
  608. return html if html.blank?
  609. safe_schemes = %w[http https mailto]
  610. # Parse and sanitize href attributes
  611. html = html.gsub(/\bhref\s*=\s*["']([^"']*)["']/i) do |match|
  612. url = Regexp.last_match(1).to_s.strip.downcase
  613. scheme = url.split(":").first
  614. if url.start_with?("/", "#") || safe_schemes.include?(scheme)
  615. match
  616. else
  617. 'href="#"'
  618. end
  619. end
  620. # Parse and sanitize src attributes
  621. html.gsub(/\bsrc\s*=\s*["']([^"']*)["']/i) do |match|
  622. url = Regexp.last_match(1).to_s.strip.downcase
  623. scheme = url.split(":").first
  624. if url.start_with?("/") || %w[http https].include?(scheme)
  625. match
  626. else
  627. 'src=""'
  628. end
  629. end
  630. end
  631. # Re-injects sanitized styles that were extracted before Rails sanitization
  632. # Replaces data-se-style-id attributes with the corresponding style attributes
  633. #
  634. # @param html [String] The HTML with data-se-style-id markers
  635. # @param style_map [Hash] Map of style IDs to sanitized CSS strings
  636. # @return [String] HTML with styles re-injected
  637. def reinject_styles(html, style_map)
  638. return html if html.blank? || style_map.empty?
  639. fragment = Nokogiri::HTML::DocumentFragment.parse(html)
  640. fragment.css("*[data-se-style-id]").each do |node|
  641. style_id = node["data-se-style-id"]
  642. safe_style = style_map[style_id]
  643. if safe_style.present?
  644. node["style"] = safe_style
  645. end
  646. # Always remove the marker attribute
  647. node.remove_attribute("data-se-style-id")
  648. end
  649. fragment.to_html
  650. rescue StandardError
  651. html
  652. end
  653. # Sanitizes inline style attributes to only allow safe CSS properties
  654. # Removes dangerous CSS like expressions, url() with javascript, etc.
  655. #
  656. # @param html [String] The HTML with style attributes
  657. # @return [String] HTML with sanitized style attributes
  658. def sanitize_inline_styles(html)
  659. return html if html.blank?
  660. fragment = Nokogiri::HTML::DocumentFragment.parse(html)
  661. fragment.css("*[style]").each do |node|
  662. original_style = node["style"].to_s
  663. safe_style = extract_safe_styles(original_style)
  664. if safe_style.present?
  665. node["style"] = safe_style
  666. else
  667. node.remove_attribute("style")
  668. end
  669. end
  670. fragment.to_html
  671. rescue StandardError
  672. html
  673. end
  674. # Extracts only safe CSS properties from a style string
  675. # Filters out dangerous values like url(), expression(), javascript:
  676. #
  677. # @param style_string [String] The raw CSS style string
  678. # @return [String] Filtered CSS declarations
  679. def extract_safe_styles(style_string)
  680. return "" if style_string.blank?
  681. safe_declarations = style_string.split(";").filter_map do |declaration|
  682. property, value = declaration.split(":", 2).map(&:strip)
  683. next unless property.present? && value.present?
  684. # Normalize property name for comparison
  685. property_lower = property.downcase.gsub(/\s+/, "-")
  686. # Only keep safe properties
  687. next unless SAFE_STYLE_PROPERTIES.include?(property_lower)
  688. # Reject dangerous values
  689. value_lower = value.downcase
  690. next if value_lower.include?("url(") && !safe_url_in_style?(value)
  691. next if value_lower.include?("expression(")
  692. next if value_lower.include?("javascript:")
  693. next if value_lower.include?("vbscript:")
  694. next if value_lower.include?("behavior:")
  695. next if value_lower.include?("-moz-binding")
  696. "#{property}: #{value}"
  697. end
  698. safe_declarations.join("; ")
  699. end
  700. # Checks if a url() in CSS is safe (http/https only)
  701. #
  702. # @param value [String] The CSS value containing url()
  703. # @return [Boolean]
  704. def safe_url_in_style?(value)
  705. # Extract URL from url(...) or url("...") or url('...')
  706. urls = value.scan(/url\s*\(\s*['"]?([^'")]+)['"]?\s*\)/i).flatten
  707. urls.all? do |url|
  708. url.strip.downcase.start_with?("http://", "https://", "/")
  709. end
  710. end
  711. # Links or creates the email sender record
  712. #
  713. # @return [void]
  714. def link_or_create_sender
  715. return if email_sender_id.present? || from_email.blank?
  716. self.email_sender = EmailSender.find_or_create_from_email(from_email, from_name)
  717. end
  718. end

app/models/transition.rb

0.0% lines covered

100.0% branches covered

3 relevant lines. 0 lines covered and 3 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. class Transition < ApplicationRecord
  2. belongs_to :resource, polymorphic: true
  3. end

app/models/user.rb

74.39% lines covered

0.0% branches covered

82 relevant lines. 61 lines covered and 21 lines missed.
6 total branches, 0 branches covered and 6 branches missed.
    
  1. # frozen_string_literal: true
  2. # User model representing registered users
  3. 1 class User < ApplicationRecord
  4. 1 extend FriendlyId
  5. 1 friendly_id :slug_candidates, use: [ :slugged, :finders ]
  6. 1 has_secure_password
  7. 1 has_many :sessions, dependent: :destroy
  8. 1 has_many :interview_applications, dependent: :destroy
  9. 1 has_many :interview_rounds, through: :interview_applications
  10. 1 has_one :preference, class_name: "UserPreference", dependent: :destroy
  11. 1 has_many :connected_accounts, dependent: :destroy
  12. 1 has_many :synced_emails, dependent: :destroy
  13. 1 has_many :opportunities, dependent: :destroy
  14. 1 has_many :saved_jobs, dependent: :destroy
  15. 1 has_many :fit_assessments, dependent: :destroy
  16. 1 has_many :interview_prep_artifacts, dependent: :destroy
  17. # =================================================================
  18. # Billing & subscriptions
  19. # =================================================================
  20. 1 has_many :billing_customers, class_name: "Billing::Customer", dependent: :destroy
  21. 1 has_many :billing_subscriptions, class_name: "Billing::Subscription", dependent: :destroy
  22. 1 has_many :billing_orders, class_name: "Billing::Order", dependent: :destroy
  23. 1 has_many :billing_entitlement_grants, class_name: "Billing::EntitlementGrant", dependent: :destroy
  24. 1 has_many :billing_usage_counters, class_name: "Billing::UsageCounter", dependent: :destroy
  25. # Resume and skill profile associations
  26. 1 has_many :user_resumes, dependent: :destroy
  27. 1 has_many :user_skills, dependent: :destroy
  28. 1 has_many :skill_tags, through: :user_skills
  29. # Current job role and company associations
  30. 1 belongs_to :current_job_role, class_name: "JobRole", optional: true
  31. 1 belongs_to :current_company, class_name: "Company", optional: true
  32. # Target job roles, companies, and domains
  33. 1 has_many :user_target_job_roles, dependent: :destroy
  34. 1 has_many :target_job_roles, through: :user_target_job_roles, source: :job_role
  35. 1 has_many :user_target_companies, dependent: :destroy
  36. 1 has_many :target_companies, through: :user_target_companies, source: :company
  37. 1 has_many :user_target_domains, dependent: :destroy
  38. 1 has_many :target_domains, through: :user_target_domains, source: :domain
  39. # Work experience (aggregated from resumes + manual entries)
  40. 1 has_many :user_work_experiences, dependent: :destroy
  41. # Virtual attribute for terms acceptance checkbox
  42. 1 attribute :terms_accepted, :boolean, default: false
  43. 1 normalizes :email_address, with: ->(e) { e.strip.downcase }
  44. 1 validates :email_address, presence: true, uniqueness: true
  45. 1 validates :terms_accepted, acceptance: { accept: true, message: "You must accept the Terms of Service and Privacy Policy" }, on: :create
  46. # Set terms_accepted_at when terms are accepted
  47. 1 before_create :set_terms_accepted_at, if: :terms_accepted
  48. # Generate token for email verification
  49. 1 generates_token_for :email_verification, expires_in: 24.hours
  50. 1 after_create :create_default_preference
  51. 1 before_create :generate_uuid
  52. # Returns the user's preference or builds a default one
  53. # @return [UserPreference]
  54. 1 def preference
  55. super || build_preference
  56. end
  57. # Returns the slug candidates for the user
  58. # @return [Array<String>]
  59. 1 def slug_candidates
  60. [
  61. :name,
  62. [ :name, :uuid ]
  63. ]
  64. end
  65. # Returns the total number of applications for this user
  66. # @return [Integer] Total application count
  67. 1 def total_applications_count
  68. interview_applications.count
  69. end
  70. # Returns applications grouped by status
  71. # @return [Hash] Applications grouped by status
  72. 1 def applications_by_status
  73. interview_applications.group_by(&:status)
  74. end
  75. # Returns the user's display name or email
  76. # @return [String]
  77. 1 def display_name
  78. name.presence || email_address.split("@").first
  79. end
  80. # Returns current role display name
  81. # @return [String, nil]
  82. 1 def current_role_name
  83. then: 0 else: 0 current_job_role&.title
  84. end
  85. # Returns current company display name
  86. # @return [String, nil]
  87. 1 def current_company_name
  88. then: 0 else: 0 current_company&.name
  89. end
  90. # Checks if the user is an admin
  91. # @return [Boolean] True if user has admin privileges
  92. 1 def admin?
  93. is_admin == true
  94. end
  95. # Returns the Google connected account if any
  96. # @return [ConnectedAccount, nil]
  97. 1 def google_account
  98. connected_accounts.google.first
  99. end
  100. # Checks if user has Google connected
  101. # @return [Boolean]
  102. 1 def google_connected?
  103. connected_accounts.google.exists?
  104. end
  105. # Checks if the user's email has been verified
  106. # @return [Boolean]
  107. 1 def email_verified?
  108. email_verified_at.present?
  109. end
  110. # Marks the user's email as verified
  111. # @return [Boolean]
  112. 1 def verify_email!
  113. update(email_verified_at: Time.current)
  114. end
  115. # Checks if user signed up via OAuth
  116. # @return [Boolean]
  117. 1 def oauth_user?
  118. oauth_provider.present?
  119. end
  120. # Internal billing override for staff/admins (all features enabled).
  121. #
  122. # @return [Boolean]
  123. 1 def billing_admin_access?
  124. Billing::AdminAccessService.new(user: self).active?
  125. end
  126. # Returns the user's aggregated skill profile
  127. # @return [ActiveRecord::Relation<UserSkill>]
  128. 1 def skill_profile
  129. user_skills.includes(:skill_tag).by_level_desc
  130. end
  131. # Returns the user's top skills
  132. # @param limit [Integer] Number of skills to return
  133. # @return [ActiveRecord::Relation<UserSkill>]
  134. 1 def top_skills(limit: 10)
  135. UserSkill.top_skills(self, limit: limit)
  136. end
  137. # Checks if user has uploaded any resumes
  138. # @return [Boolean]
  139. 1 def has_resumes?
  140. user_resumes.exists?
  141. end
  142. # Returns the count of analyzed resumes
  143. # @return [Integer]
  144. 1 def analyzed_resumes_count
  145. user_resumes.analyzed.count
  146. end
  147. 1 private
  148. 1 def create_default_preference
  149. else: 0 then: 0 create_preference! unless preference.persisted?
  150. end
  151. 1 def set_terms_accepted_at
  152. self.terms_accepted_at = Time.current
  153. end
  154. # Generates a UUID for the user
  155. # @return [String]
  156. 1 def generate_uuid
  157. self.uuid = SecureRandom.uuid
  158. end
  159. end

app/models/user_preference.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # UserPreference model for managing user settings and preferences
  3. class UserPreference < ApplicationRecord
  4. VIEWS = [ "kanban", "table" ].freeze
  5. THEMES = [ "light", "dark", "system" ].freeze
  6. AI_INSIGHTS_FREQUENCIES = [ "daily", "weekly", "on_demand" ].freeze
  7. belongs_to :user
  8. # Validations
  9. validates :user, presence: true, uniqueness: true
  10. validates :preferred_view, inclusion: { in: VIEWS + [ "list" ] } # Allow "list" for backward compatibility
  11. validates :theme, inclusion: { in: THEMES }
  12. validates :ai_insights_frequency, inclusion: { in: AI_INSIGHTS_FREQUENCIES }, allow_nil: true
  13. validates :data_retention_days, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
  14. # Normalize view preference (convert "list" to "table")
  15. before_validation :normalize_view_preference
  16. # Returns true if user prefers kanban view
  17. # @return [Boolean]
  18. def kanban_view?
  19. preferred_view == "kanban"
  20. end
  21. # Returns true if user prefers table view
  22. # @return [Boolean]
  23. def table_view?
  24. preferred_view == "table" || preferred_view == "list"
  25. end
  26. # Returns true if user prefers list view (deprecated, use table_view?)
  27. # @return [Boolean]
  28. def list_view?
  29. table_view?
  30. end
  31. # Returns true if AI feedback analysis is enabled
  32. # @return [Boolean]
  33. def ai_feedback_analysis?
  34. ai_feedback_analysis != false
  35. end
  36. # Returns true if AI interview prep is enabled
  37. # @return [Boolean]
  38. def ai_interview_prep?
  39. ai_interview_prep != false
  40. end
  41. # Returns true if weekly digest emails are enabled
  42. # @return [Boolean]
  43. def email_weekly_digest?
  44. email_weekly_digest != false
  45. end
  46. # Returns true if interview reminder emails are enabled
  47. # @return [Boolean]
  48. def email_interview_reminders?
  49. email_interview_reminders != false
  50. end
  51. # Returns the effective AI insights frequency, defaulting to weekly
  52. # @return [String]
  53. def effective_ai_insights_frequency
  54. ai_insights_frequency || "weekly"
  55. end
  56. # Returns true if data should be retained indefinitely
  57. # @return [Boolean]
  58. def retain_data_forever?
  59. data_retention_days.nil? || data_retention_days == 0
  60. end
  61. private
  62. # Normalize "list" to "table" for consistency
  63. def normalize_view_preference
  64. self.preferred_view = "table" if preferred_view == "list"
  65. end
  66. # Returns true if dark mode is enabled
  67. # @return [Boolean]
  68. def dark_mode?
  69. theme == "dark"
  70. end
  71. end

app/models/user_resume.rb

0.0% lines covered

100.0% branches covered

114 relevant lines. 0 lines covered and 114 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # UserResume model representing uploaded CVs/resumes for skill extraction
  3. #
  4. # @example
  5. # resume = user.user_resumes.create!(name: "Backend - Generic", file: uploaded_file)
  6. # AnalyzeResumeJob.perform_later(resume)
  7. #
  8. class UserResume < ApplicationRecord
  9. extend FriendlyId
  10. friendly_id :slug_candidates, use: [ :slugged, :finders ]
  11. # Constants
  12. ALLOWED_CONTENT_TYPES = %w[
  13. application/pdf
  14. application/msword
  15. application/vnd.openxmlformats-officedocument.wordprocessingml.document
  16. text/plain
  17. ].freeze
  18. MAX_FILE_SIZE = 10.megabytes
  19. # Enums
  20. enum :purpose, {
  21. generic: 0,
  22. company_specific: 1,
  23. role_specific: 2
  24. }, prefix: true
  25. enum :analysis_status, {
  26. pending: 0,
  27. processing: 1,
  28. completed: 2,
  29. failed: 3
  30. }, prefix: true
  31. # Associations
  32. belongs_to :user
  33. has_many :resume_skills, dependent: :destroy
  34. has_many :skill_tags, through: :resume_skills
  35. has_many :resume_work_experiences, dependent: :destroy
  36. # Target roles and companies (many-to-many)
  37. has_many :user_resume_target_job_roles, dependent: :destroy
  38. has_many :target_job_roles, through: :user_resume_target_job_roles, source: :job_role
  39. has_many :user_resume_target_companies, dependent: :destroy
  40. has_many :target_companies, through: :user_resume_target_companies, source: :company
  41. # ActiveStorage
  42. has_one_attached :file
  43. # Validations
  44. validates :name, presence: true
  45. validates :file, presence: true, on: :create
  46. validate :acceptable_file, if: -> { file.attached? }
  47. # Scopes
  48. scope :by_user, ->(user) { where(user: user) }
  49. scope :analyzed, -> { where(analysis_status: :completed) }
  50. scope :pending_analysis, -> { where(analysis_status: :pending) }
  51. scope :recent_first, -> { order(created_at: :desc) }
  52. scope :by_purpose, ->(purpose) { where(purpose: purpose) }
  53. # Callbacks
  54. after_create_commit :enqueue_analysis
  55. def slug_candidates
  56. [
  57. :name,
  58. [ :name, :purpose ],
  59. [ :name, :purpose, :user_uuid ]
  60. ]
  61. end
  62. def user_uuid
  63. user.uuid
  64. end
  65. # Returns the file extension
  66. #
  67. # @return [String, nil] File extension (e.g., "pdf", "docx")
  68. def file_extension
  69. return nil unless file.attached?
  70. file.filename.extension.downcase
  71. end
  72. # Returns a human-readable file type
  73. #
  74. # @return [String] File type description
  75. def file_type
  76. case file_extension
  77. when "pdf" then "PDF"
  78. when "doc" then "Word (DOC)"
  79. when "docx" then "Word (DOCX)"
  80. when "txt" then "Plain Text"
  81. else "Unknown"
  82. end
  83. end
  84. # Checks if analysis is complete
  85. #
  86. # @return [Boolean]
  87. def analyzed?
  88. analysis_status_completed?
  89. end
  90. # Checks if analysis is in progress
  91. #
  92. # @return [Boolean]
  93. def analyzing?
  94. analysis_status_processing?
  95. end
  96. # Checks if this resume has any target roles
  97. #
  98. # @return [Boolean]
  99. def has_target_roles?
  100. target_job_roles.exists?
  101. end
  102. # Checks if this resume has any target companies
  103. #
  104. # @return [Boolean]
  105. def has_target_companies?
  106. target_companies.exists?
  107. end
  108. # Returns a summary of targets for display
  109. #
  110. # @return [String, nil]
  111. def targets_summary
  112. parts = []
  113. parts << target_job_roles.pluck(:title).join(", ") if has_target_roles?
  114. parts << "@ #{target_companies.pluck(:name).join(", ")}" if has_target_companies?
  115. parts.any? ? parts.join(" ") : nil
  116. end
  117. # Returns the effective proficiency level for a skill
  118. # Prefers user_level over model_level
  119. #
  120. # @param skill_tag [SkillTag] The skill to check
  121. # @return [Integer, nil] Proficiency level 1-5
  122. def proficiency_for(skill_tag)
  123. resume_skill = resume_skills.find_by(skill_tag: skill_tag)
  124. return nil unless resume_skill
  125. resume_skill.user_level || resume_skill.model_level
  126. end
  127. # Marks analysis as started
  128. #
  129. # @return [Boolean]
  130. def start_analysis!
  131. update!(analysis_status: :processing)
  132. end
  133. # Marks analysis as completed
  134. #
  135. # @param summary [String, nil] AI-generated summary
  136. # @return [Boolean]
  137. def complete_analysis!(summary: nil)
  138. update!(
  139. analysis_status: :completed,
  140. analyzed_at: Time.current,
  141. analysis_summary: summary
  142. )
  143. end
  144. # Marks analysis as failed
  145. #
  146. # @param error_message [String, nil] Error description
  147. # @return [Boolean]
  148. def fail_analysis!(error_message: nil)
  149. update!(
  150. analysis_status: :failed,
  151. extracted_data: extracted_data.merge(error: error_message)
  152. )
  153. end
  154. private
  155. # Validates attached file type and size
  156. def acceptable_file
  157. unless file.blob.byte_size <= MAX_FILE_SIZE
  158. errors.add(:file, "is too large (max #{MAX_FILE_SIZE / 1.megabyte}MB)")
  159. end
  160. unless ALLOWED_CONTENT_TYPES.include?(file.blob.content_type)
  161. errors.add(:file, "must be a PDF, Word document, or plain text file")
  162. end
  163. end
  164. # Enqueues background analysis job
  165. def enqueue_analysis
  166. AnalyzeResumeJob.perform_later(self)
  167. end
  168. end

app/models/user_resume_target_company.rb

0.0% lines covered

100.0% branches covered

5 relevant lines. 0 lines covered and 5 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Join model connecting UserResume to target Companies
  3. class UserResumeTargetCompany < ApplicationRecord
  4. belongs_to :user_resume
  5. belongs_to :company
  6. validates :user_resume_id, uniqueness: { scope: :company_id }
  7. end

app/models/user_resume_target_job_role.rb

0.0% lines covered

100.0% branches covered

5 relevant lines. 0 lines covered and 5 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Join model connecting UserResume to target JobRoles
  3. class UserResumeTargetJobRole < ApplicationRecord
  4. belongs_to :user_resume
  5. belongs_to :job_role
  6. validates :user_resume_id, uniqueness: { scope: :job_role_id }
  7. end

app/models/user_skill.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # UserSkill model representing aggregated skill profile across all resumes
  3. #
  4. # Computed from ResumeSkills with weighted averaging based on recency and purpose
  5. #
  6. # @example
  7. # user_skill = user.user_skills.find_by(skill_tag: ruby_skill)
  8. # user_skill.aggregated_level # => 4.2
  9. # user_skill.resume_count # => 3
  10. #
  11. class UserSkill < ApplicationRecord
  12. # Associations
  13. belongs_to :user
  14. belongs_to :skill_tag
  15. # Delegations
  16. delegate :name, to: :skill_tag, prefix: :skill
  17. # Validations
  18. validates :aggregated_level, presence: true, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 5 }
  19. validates :user_id, uniqueness: { scope: :skill_tag_id, message: "skill already exists for this user" }
  20. validates :resume_count, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
  21. # Scopes
  22. scope :by_category, ->(category) { where(category: category) }
  23. scope :strong_skills, -> { where("aggregated_level >= ?", 4.0) }
  24. scope :moderate_skills, -> { where(aggregated_level: 2.5..3.9) }
  25. scope :developing_skills, -> { where("aggregated_level < ?", 2.5) }
  26. scope :by_level_desc, -> { order(aggregated_level: :desc) }
  27. scope :by_level_asc, -> { order(aggregated_level: :asc) }
  28. scope :alphabetical, -> { joins(:skill_tag).order("skill_tags.name ASC") }
  29. scope :most_demonstrated, -> { order(resume_count: :desc) }
  30. scope :recent, -> { order(last_demonstrated_at: :desc) }
  31. # Returns proficiency as a rounded integer
  32. #
  33. # @return [Integer] Rounded proficiency level 1-5
  34. def rounded_level
  35. aggregated_level.round
  36. end
  37. # Returns a human-readable proficiency label
  38. #
  39. # @return [String] Proficiency description
  40. def proficiency_label
  41. case rounded_level
  42. when 1 then "Beginner"
  43. when 2 then "Elementary"
  44. when 3 then "Intermediate"
  45. when 4 then "Advanced"
  46. when 5 then "Expert"
  47. else "Unknown"
  48. end
  49. end
  50. # Returns confidence as a percentage
  51. #
  52. # @return [Integer] Confidence percentage 0-100
  53. def confidence_percentage
  54. return 0 unless confidence_score
  55. (confidence_score * 100).round
  56. end
  57. # Checks if this is a strong skill (4+)
  58. #
  59. # @return [Boolean]
  60. def strong?
  61. aggregated_level >= 4.0
  62. end
  63. # Checks if this is a developing skill (<2.5)
  64. #
  65. # @return [Boolean]
  66. def developing?
  67. aggregated_level < 2.5
  68. end
  69. # Returns the source resumes for this skill
  70. #
  71. # @return [ActiveRecord::Relation<UserResume>]
  72. def source_resumes
  73. UserResume.joins(:resume_skills)
  74. .where(user: user, resume_skills: { skill_tag: skill_tag })
  75. .distinct
  76. end
  77. # Class method to get skills grouped by category
  78. #
  79. # @param user [User] The user
  80. # @return [Hash] Skills grouped by category
  81. def self.grouped_by_category(user)
  82. where(user: user)
  83. .includes(:skill_tag)
  84. .order(aggregated_level: :desc)
  85. .group_by(&:category)
  86. end
  87. # Class method to get top N skills for a user
  88. #
  89. # @param user [User] The user
  90. # @param limit [Integer] Number of skills to return
  91. # @return [ActiveRecord::Relation<UserSkill>]
  92. def self.top_skills(user, limit: 10)
  93. where(user: user).by_level_desc.limit(limit)
  94. end
  95. # Class method to get skills needing development
  96. #
  97. # @param user [User] The user
  98. # @param limit [Integer] Number of skills to return
  99. # @return [ActiveRecord::Relation<UserSkill>]
  100. def self.development_areas(user, limit: 5)
  101. where(user: user).developing_skills.by_level_asc.limit(limit)
  102. end
  103. end

app/models/user_target_company.rb

0.0% lines covered

100.0% branches covered

8 relevant lines. 0 lines covered and 8 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # UserTargetCompany join model for user's target companies
  3. class UserTargetCompany < ApplicationRecord
  4. belongs_to :user
  5. belongs_to :company
  6. validates :user, presence: true
  7. validates :company, presence: true
  8. validates :company_id, uniqueness: { scope: :user_id }
  9. scope :ordered, -> { order(priority: :asc, created_at: :asc) }
  10. end

app/models/user_target_domain.rb

0.0% lines covered

100.0% branches covered

8 relevant lines. 0 lines covered and 8 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # UserTargetDomain join model for user's target domains
  3. class UserTargetDomain < ApplicationRecord
  4. belongs_to :user
  5. belongs_to :domain
  6. validates :user, presence: true
  7. validates :domain, presence: true
  8. validates :domain_id, uniqueness: { scope: :user_id }
  9. scope :ordered, -> { order(priority: :asc, created_at: :asc) }
  10. end

app/models/user_target_job_role.rb

0.0% lines covered

100.0% branches covered

8 relevant lines. 0 lines covered and 8 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # UserTargetJobRole join model for user's target job roles
  3. class UserTargetJobRole < ApplicationRecord
  4. belongs_to :user
  5. belongs_to :job_role
  6. validates :user, presence: true
  7. validates :job_role, presence: true
  8. validates :job_role_id, uniqueness: { scope: :user_id }
  9. scope :ordered, -> { order(priority: :asc, created_at: :asc) }
  10. end

app/models/user_work_experience.rb

0.0% lines covered

100.0% branches covered

21 relevant lines. 0 lines covered and 21 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # UserWorkExperience is a merged, user-level view of work history aggregated across resumes.
  3. # It preserves provenance via UserWorkExperienceSource.
  4. # Can also be manually created/edited by users.
  5. class UserWorkExperience < ApplicationRecord
  6. belongs_to :user
  7. belongs_to :company, optional: true
  8. belongs_to :job_role, optional: true
  9. has_many :user_work_experience_sources, dependent: :destroy
  10. has_many :resume_work_experiences, through: :user_work_experience_sources
  11. has_many :user_work_experience_skills, dependent: :destroy
  12. has_many :skill_tags, through: :user_work_experience_skills
  13. # Source type: ai_extracted (from resume analysis) or manual (user-created)
  14. enum :source_type, [ :ai_extracted, :manual ], default: :ai_extracted
  15. validates :role_title, presence: true
  16. validates :company_name, presence: true
  17. scope :reverse_chronological, -> { order(Arel.sql("COALESCE(end_date, start_date) DESC NULLS LAST"), created_at: :desc) }
  18. scope :ai_extracted_only, -> { where(source_type: :ai_extracted) }
  19. scope :manual_only, -> { where(source_type: :manual) }
  20. # @return [String]
  21. def display_company_name
  22. company&.name.presence || company_name.to_s
  23. end
  24. # @return [String]
  25. def display_role_title
  26. job_role&.title.presence || role_title.to_s
  27. end
  28. end

app/models/user_work_experience_skill.rb

0.0% lines covered

100.0% branches covered

6 relevant lines. 0 lines covered and 6 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Aggregated join between UserWorkExperience and SkillTag.
  3. # Tracks how many source resume experiences mention the skill and when it was last used.
  4. class UserWorkExperienceSkill < ApplicationRecord
  5. belongs_to :user_work_experience
  6. belongs_to :skill_tag
  7. validates :user_work_experience_id, uniqueness: { scope: :skill_tag_id }
  8. validates :source_count, numericality: { greater_than_or_equal_to: 0 }
  9. end

app/models/user_work_experience_source.rb

0.0% lines covered

100.0% branches covered

5 relevant lines. 0 lines covered and 5 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Provenance join from a merged UserWorkExperience back to individual ResumeWorkExperience rows.
  3. class UserWorkExperienceSource < ApplicationRecord
  4. belongs_to :user_work_experience
  5. belongs_to :resume_work_experience
  6. validates :user_work_experience_id, uniqueness: { scope: :resume_work_experience_id }
  7. end

app/services/ai/api_logger_service.rb

0.0% lines covered

100.0% branches covered

244 relevant lines. 0 lines covered and 244 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Service for recording LLM API calls with full observability
  4. #
  5. # Wraps LLM calls to capture timing, tokens, costs, and full
  6. # request/response payloads for debugging and analytics.
  7. #
  8. # @example
  9. # logger = Ai::ApiLoggerService.new(
  10. # operation_type: :job_extraction,
  11. # loggable: job_listing,
  12. # provider: "anthropic",
  13. # model: "claude-sonnet-4-20250514"
  14. # )
  15. # result = logger.record do |log_context|
  16. # # Make AI call here
  17. # # Return { content: "...", input_tokens: 100, output_tokens: 50 }
  18. # end
  19. #
  20. class ApiLoggerService
  21. attr_reader :log
  22. # Initialize the logger service
  23. #
  24. # @param operation_type [String, Symbol] The type of operation (job_extraction, email_extraction, resume_extraction)
  25. # @param loggable [ApplicationRecord, nil] The object being processed (JobListing, Opportunity, UserResume)
  26. # @param provider [String] The AI provider name
  27. # @param model [String] The model identifier
  28. # @param llm_prompt [Ai::LlmPrompt, nil] Optional prompt template used
  29. def initialize(operation_type:, loggable: nil, provider:, model:, llm_prompt: nil)
  30. @operation_type = operation_type.to_s
  31. @loggable = loggable
  32. @provider = provider
  33. @model = model
  34. @llm_prompt = llm_prompt
  35. @log = nil
  36. end
  37. # Records an LLM API call with full observability
  38. #
  39. # Captures timing, tokens, and payloads. The block should return a hash with:
  40. # - content: The extracted content/response
  41. # - input_tokens: Number of input tokens
  42. # - output_tokens: Number of output tokens
  43. # - confidence: Confidence score (0.0-1.0)
  44. # - error: Error message if failed
  45. # - error_type: Type of error if failed
  46. #
  47. # @param prompt [String, nil] The prompt text (for logging)
  48. # @param content_size [Integer, nil] Size of content in bytes
  49. # @yield [log_context] Block that performs the AI call
  50. # @return [Hash] The result from the block, with logging metadata added
  51. def record(prompt: nil, content_size: nil)
  52. start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  53. # Create initial log record
  54. @log = Ai::LlmApiLog.new(
  55. operation_type: @operation_type,
  56. loggable: @loggable,
  57. llm_prompt: @llm_prompt,
  58. provider: @provider,
  59. model: @model,
  60. content_size: content_size,
  61. request_payload: build_request_payload(prompt: prompt),
  62. status: :success
  63. )
  64. begin
  65. # Execute the AI call
  66. result = yield(self)
  67. # Calculate latency
  68. end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  69. latency_ms = ((end_time - start_time) * 1000).round
  70. # Update log with results
  71. @log.assign_attributes(
  72. latency_ms: latency_ms,
  73. input_tokens: result[:input_tokens],
  74. output_tokens: result[:output_tokens],
  75. confidence_score: result[:confidence],
  76. request_payload: build_request_payload(prompt: prompt, result: result),
  77. response_payload: build_response_payload(result),
  78. extracted_fields: extract_field_names(result),
  79. status: determine_status(result)
  80. )
  81. # Handle errors in result
  82. if result[:error].present?
  83. @log.assign_attributes(
  84. error_message: result[:error],
  85. error_type: result[:error_type] || classify_error(result[:error])
  86. )
  87. end
  88. @log.save!
  89. # Return result with log reference
  90. result.merge(llm_api_log_id: @log.id)
  91. rescue => e
  92. # Calculate latency even for failures
  93. end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  94. latency_ms = ((end_time - start_time) * 1000).round
  95. # Record the error
  96. @log.assign_attributes(
  97. latency_ms: latency_ms,
  98. status: classify_exception_status(e),
  99. error_type: e.class.name,
  100. error_message: e.message,
  101. response_payload: build_exception_response_payload(e)
  102. )
  103. @log.save!
  104. # Notify with rich context for easier debugging (thread/trace/log ids, etc).
  105. # This is intentionally best-effort so logging never hides the original exception.
  106. begin
  107. Ai::ErrorReporter.notify(
  108. e,
  109. operation: @operation_type,
  110. provider: @provider,
  111. model: @model,
  112. user: @loggable.is_a?(User) ? @loggable : nil,
  113. thread: @loggable.is_a?(Assistant::ChatThread) ? @loggable : nil,
  114. trace_id: nil,
  115. llm_api_log_id: @log.id,
  116. extra: { llm_prompt_id: @llm_prompt&.id, loggable_type: @loggable&.class&.name, loggable_id: @loggable&.id }
  117. )
  118. rescue StandardError
  119. # best-effort only
  120. end
  121. # Re-raise the exception
  122. raise
  123. end
  124. end
  125. # Records a simple result without wrapping
  126. #
  127. # Use this when you've already made the AI call and just want to log it.
  128. #
  129. # @param result [Hash] The extraction result
  130. # @param latency_ms [Integer] Latency in milliseconds
  131. # @param prompt [String, nil] The prompt text
  132. # @param content_size [Integer, nil] Size of content
  133. # @return [Ai::LlmApiLog] The created log record
  134. def record_result(result, latency_ms:, prompt: nil, content_size: nil)
  135. @log = Ai::LlmApiLog.create!(
  136. operation_type: @operation_type,
  137. loggable: @loggable,
  138. llm_prompt: @llm_prompt,
  139. provider: @provider,
  140. model: @model,
  141. content_size: content_size,
  142. request_payload: build_request_payload(prompt: prompt, result: result),
  143. response_payload: build_response_payload(result),
  144. latency_ms: latency_ms,
  145. input_tokens: result[:input_tokens],
  146. output_tokens: result[:output_tokens],
  147. confidence_score: result[:confidence],
  148. extracted_fields: extract_field_names(result),
  149. status: determine_status(result),
  150. error_message: result[:error],
  151. error_type: result[:error_type] || (result[:error].present? ? classify_error(result[:error]) : nil)
  152. )
  153. end
  154. private
  155. # Determines the status based on the result
  156. #
  157. # @param result [Hash] The extraction result
  158. # @return [Symbol] The status
  159. def determine_status(result)
  160. return :rate_limited if result[:rate_limit]
  161. return :timeout if result[:timeout]
  162. return :error if result[:error].present?
  163. :success
  164. end
  165. # Classifies exception into status
  166. #
  167. # @param exception [Exception] The exception
  168. # @return [Symbol] The status
  169. def classify_exception_status(exception)
  170. message = exception.message.to_s.downcase
  171. return :rate_limited if message.include?("rate") && message.include?("limit")
  172. return :timeout if message.include?("timeout") || exception.is_a?(Timeout::Error)
  173. :error
  174. end
  175. # Classifies error message into type
  176. #
  177. # @param error [String] The error message
  178. # @return [String] Error type
  179. def classify_error(error)
  180. return "rate_limit" if error.to_s.downcase.include?("rate")
  181. return "timeout" if error.to_s.downcase.include?("timeout")
  182. return "parsing" if error.to_s.downcase.include?("json") || error.to_s.downcase.include?("parse")
  183. return "authentication" if error.to_s.downcase.include?("auth") || error.to_s.downcase.include?("key")
  184. "unknown"
  185. end
  186. # Builds response payload for storage
  187. #
  188. # Stores comprehensive extraction data for debugging purposes.
  189. #
  190. # @param result [Hash] The extraction result
  191. # @return [Hash] Payload for storage
  192. def build_response_payload(result)
  193. payload = {}
  194. # Provider-native request/response (raw, best-effort) for debugging.
  195. # These are intentionally separate from parsed/extracted fields.
  196. if result[:provider_response].present?
  197. payload[:provider_response] = sanitize_payload_for_storage(result[:provider_response])
  198. end
  199. if result[:provider_error_response].present?
  200. payload[:provider_error_response] = sanitize_payload_for_storage(result[:provider_error_response])
  201. end
  202. payload[:http_status] = result[:http_status] if result[:http_status].present?
  203. if result[:response_headers].present?
  204. payload[:response_headers] = sanitize_payload_for_storage(result[:response_headers], max_string_length: 10_000)
  205. end
  206. payload[:provider_endpoint] = result[:provider_endpoint] if result[:provider_endpoint].present?
  207. # For assistant operations, store the full text response for debugging/replay.
  208. if @operation_type.to_s.start_with?("assistant_") && result[:content].present?
  209. payload[:text] = truncate_for_storage(result[:content], 10_000)
  210. end
  211. # Core extraction fields for job extraction
  212. job_extraction_fields = %i[
  213. title company job_role description about_company company_culture
  214. requirements responsibilities location remote_type
  215. salary_min salary_max salary_currency equity_info benefits perks
  216. notes
  217. ]
  218. # Store all extracted job fields with their values (truncated for long text)
  219. job_extraction_fields.each do |field|
  220. next unless result[field].present?
  221. value = result[field]
  222. payload[field] = case value
  223. when String
  224. truncate_for_display(value, 500)
  225. when Array, Hash
  226. value
  227. else
  228. value
  229. end
  230. end
  231. # Always include confidence
  232. payload[:confidence] = result[:confidence] if result[:confidence].present?
  233. # Interview prep payloads: store small, structured preview fields.
  234. if @operation_type.to_s.start_with?("interview_prep_")
  235. payload[:match_label] = result[:match_label] if result[:match_label].present?
  236. payload[:strong_in] = Array(result[:strong_in]).first(8) if result[:strong_in].present?
  237. payload[:partial_in] = Array(result[:partial_in]).first(8) if result[:partial_in].present?
  238. payload[:missing_or_risky] = Array(result[:missing_or_risky]).first(8) if result[:missing_or_risky].present?
  239. payload[:focus_areas_count] = Array(result.dig(:focus_areas)).size if result[:focus_areas].present?
  240. payload[:questions_count] = Array(result.dig(:questions)).size if result[:questions].present?
  241. payload[:strengths_count] = Array(result.dig(:strengths)).size if result[:strengths].present?
  242. end
  243. # Include skills summary for resume extraction
  244. if result[:skills].is_a?(Array)
  245. payload[:skills_count] = result[:skills].size
  246. payload[:skills_preview] = result[:skills].first(5).map { |s| s[:name] rescue s.to_s }
  247. end
  248. # Include raw LLM response if available (truncated but longer for debugging)
  249. if result[:raw_response].present?
  250. payload[:raw_response] = truncate_for_storage(result[:raw_response], 10_000)
  251. end
  252. # Include error info
  253. payload[:error] = result[:error] if result[:error].present?
  254. # Include any custom sections
  255. payload[:custom_sections] = result[:custom_sections] if result[:custom_sections].present?
  256. payload
  257. end
  258. # Builds request payload for storage
  259. #
  260. # Captures both the "prompt string" and the provider-native request payload
  261. # (if the caller/provider provides it).
  262. #
  263. # @param prompt [String, nil]
  264. # @param result [Hash, nil]
  265. # @return [Hash]
  266. def build_request_payload(prompt:, result: nil)
  267. payload = {}
  268. payload[:prompt] = truncate_for_storage(prompt) if prompt.present?
  269. if result.is_a?(Hash)
  270. if result[:provider_request].present?
  271. payload[:provider_request] = sanitize_payload_for_storage(result[:provider_request])
  272. end
  273. payload[:provider_endpoint] = result[:provider_endpoint] if result[:provider_endpoint].present?
  274. end
  275. payload
  276. end
  277. # Attempts to capture a provider/client response from raised exceptions (best-effort).
  278. #
  279. # @param exception [Exception]
  280. # @return [Hash]
  281. def build_exception_response_payload(exception)
  282. payload = {
  283. exception_class: exception.class.name,
  284. exception_message: exception.message
  285. }
  286. if exception.respond_to?(:response)
  287. payload[:exception_response] = sanitize_payload_for_storage(exception.response, max_string_length: 10_000)
  288. end
  289. if exception.respond_to?(:status)
  290. payload[:http_status] = exception.status
  291. end
  292. payload.compact
  293. rescue StandardError
  294. { exception_class: exception.class.name, exception_message: exception.message }
  295. end
  296. # Sanitizes nested payloads for JSONB storage and UI rendering.
  297. #
  298. # - Truncates long strings
  299. # - Converts SDK objects via `to_h` when available
  300. # - Limits recursion depth to avoid pathological payloads
  301. #
  302. # @param value [Object]
  303. # @param max_string_length [Integer]
  304. # @param max_depth [Integer]
  305. # @param depth [Integer]
  306. # @return [Object]
  307. def sanitize_payload_for_storage(value, max_string_length: 50_000, max_depth: 8, depth: 0)
  308. return nil if value.nil?
  309. return "[TRUNCATED: max_depth=#{max_depth}]" if depth >= max_depth
  310. if value.is_a?(String)
  311. return truncate_for_storage(value, max_string_length)
  312. end
  313. if value.is_a?(Numeric) || value == true || value == false
  314. return value
  315. end
  316. if value.is_a?(Hash)
  317. return value.to_h.each_with_object({}) do |(k, v), acc|
  318. acc[k.to_s] = sanitize_payload_for_storage(v, max_string_length: max_string_length, max_depth: max_depth, depth: depth + 1)
  319. end
  320. end
  321. if value.is_a?(Array)
  322. return value.first(200).map { |v| sanitize_payload_for_storage(v, max_string_length: max_string_length, max_depth: max_depth, depth: depth + 1) }
  323. end
  324. if value.respond_to?(:to_h)
  325. return sanitize_payload_for_storage(value.to_h, max_string_length: max_string_length, max_depth: max_depth, depth: depth + 1)
  326. end
  327. sanitize_payload_for_storage(value.to_s, max_string_length: max_string_length, max_depth: max_depth, depth: depth + 1)
  328. end
  329. # Truncates content for display in UI
  330. #
  331. # @param content [String, nil] The content to truncate
  332. # @param max_length [Integer] Maximum length
  333. # @return [String, nil] Truncated content
  334. def truncate_for_display(content, max_length = 500)
  335. return nil if content.nil?
  336. return content if content.length <= max_length
  337. content[0...max_length] + "..."
  338. end
  339. # Extracts field names that were successfully populated
  340. #
  341. # @param result [Hash] The extraction result
  342. # @return [Array<String>] Field names
  343. def extract_field_names(result)
  344. # Common job extraction fields
  345. job_fields = %i[
  346. title company job_role description requirements responsibilities
  347. location remote_type salary_min salary_max salary_currency
  348. equity_info benefits perks
  349. ]
  350. # Email extraction fields
  351. email_fields = %i[
  352. company_name job_role_title job_url recruiter_info key_details
  353. all_links is_forwarded original_source
  354. ]
  355. # Resume extraction fields
  356. resume_fields = %i[
  357. skills summary strengths domains
  358. ]
  359. all_fields = job_fields + email_fields + resume_fields
  360. all_fields.select { |f| result[f].present? }.map(&:to_s)
  361. end
  362. # Truncates content for storage to prevent bloat
  363. #
  364. # @param content [String, nil] The content to truncate
  365. # @param max_length [Integer] Maximum length
  366. # @return [String, nil] Truncated content
  367. def truncate_for_storage(content, max_length = 50_000)
  368. return nil if content.nil?
  369. return content if content.length <= max_length
  370. content[0...max_length] + "\n\n[TRUNCATED - original length: #{content.length}]"
  371. end
  372. end
  373. end

app/services/ai/error_reporter.rb

0.0% lines covered

100.0% branches covered

20 relevant lines. 0 lines covered and 20 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Centralized error reporting for AI/Assistant flows.
  4. #
  5. # Use this instead of ad-hoc `Rails.logger.error` so we consistently capture
  6. # exceptions with enough context to debug: thread/turn/trace/provider/model/log.
  7. class ErrorReporter
  8. # @param exception [Exception]
  9. # @param operation [String, Symbol]
  10. # @param provider [String, nil]
  11. # @param model [String, nil]
  12. # @param user [User, nil]
  13. # @param thread [Assistant::ChatThread, nil]
  14. # @param turn [Assistant::Turn, nil]
  15. # @param trace_id [String, nil]
  16. # @param llm_api_log_id [Integer, nil]
  17. # @param extra [Hash]
  18. def self.notify(exception, operation:, provider: nil, model: nil, user: nil, thread: nil, turn: nil, trace_id: nil, llm_api_log_id: nil, extra: {})
  19. ai_context = {
  20. operation: operation.to_s,
  21. provider_name: provider,
  22. model_identifier: model,
  23. user_id: user&.id,
  24. thread_id: thread&.id,
  25. thread_uuid: thread&.respond_to?(:uuid) ? thread.uuid : nil,
  26. turn_id: turn&.id,
  27. trace_id: trace_id,
  28. llm_api_log_id: llm_api_log_id
  29. }.merge(extra.to_h).compact
  30. ExceptionNotifier.notify_ai_error(exception, ai_context)
  31. rescue StandardError => e
  32. Rails.logger.error("[Ai::ErrorReporter] Failed to notify: #{e.class}: #{e.message}")
  33. end
  34. end
  35. end

app/services/ai/prompt_builder_service.rb

0.0% lines covered

100.0% branches covered

27 relevant lines. 0 lines covered and 27 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Service for building LLM prompts from prompt templates and variables.
  4. #
  5. # Uses the active prompt record when available; otherwise falls back to the
  6. # class-level default prompt template and performs simple variable substitution.
  7. #
  8. # @example
  9. # prompt = Ai::PromptBuilderService.new(
  10. # prompt_class: Ai::EmailExtractionPrompt,
  11. # variables: { subject: "Hello", body: "..." }
  12. # ).run
  13. class PromptBuilderService
  14. # @param prompt_class [Class] Prompt class (e.g., Ai::EmailExtractionPrompt)
  15. # @param variables [Hash] Variables used for prompt substitution
  16. def initialize(prompt_class:, variables:)
  17. @prompt_class = prompt_class
  18. @variables = variables || {}
  19. validate!
  20. end
  21. # Builds the prompt string.
  22. #
  23. # @return [String]
  24. def run
  25. template_record = prompt_class.active_prompt
  26. return template_record.build_prompt(variables) if template_record
  27. build_from_default_template
  28. end
  29. private
  30. attr_reader :prompt_class, :variables
  31. def validate!
  32. return if prompt_class.respond_to?(:active_prompt) && prompt_class.respond_to?(:default_prompt_template)
  33. raise ArgumentError, "prompt_class must respond to active_prompt and default_prompt_template"
  34. end
  35. def build_from_default_template
  36. prompt = prompt_class.default_prompt_template.dup
  37. variables.each do |key, value|
  38. prompt.gsub!("{{#{key}}}", value.to_s)
  39. end
  40. prompt
  41. end
  42. end
  43. end

app/services/ai/provider_runner_service.rb

0.0% lines covered

100.0% branches covered

169 relevant lines. 0 lines covered and 169 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Service for running LLM providers with standardized logging and fallback.
  4. #
  5. # Wraps provider execution with ApiLoggerService.record, captures
  6. # provider response metadata, and supports custom parsing/acceptance.
  7. #
  8. # @example
  9. # runner = Ai::ProviderRunnerService.new(
  10. # provider_chain: providers,
  11. # prompt: prompt,
  12. # content_size: content_size,
  13. # system_message: system_message,
  14. # provider_for: ->(name) { provider_for(name) },
  15. # logger_builder: ->(name, provider) { build_logger(name, provider) }
  16. # )
  17. # result = runner.run do |response|
  18. # parsed = parse_response(response[:content])
  19. # log_data = { confidence: parsed[:confidence_score] }
  20. # accept = parsed[:confidence_score].to_f >= 0.6
  21. # [ parsed, log_data, accept ]
  22. # end
  23. #
  24. # @return [Hash] result with success/error status
  25. class ProviderRunnerService < ApplicationService
  26. # @param provider_chain [Array<String>] Ordered provider names
  27. # @param prompt [String] Prompt text
  28. # @param content_size [Integer] Content size for logging
  29. # @param system_message [String, nil] System message for providers
  30. # @param provider_for [Proc] Proc that returns provider instance
  31. # @param logger_builder [Proc] Proc that returns ApiLoggerService
  32. # @param run_options [Hash] Additional provider.run options
  33. # @param on_exception [Proc, nil] Optional exception handler
  34. def initialize(
  35. provider_chain:,
  36. prompt:,
  37. content_size:,
  38. system_message: nil,
  39. provider_for:,
  40. logger_builder:,
  41. run_options: {},
  42. on_exception: nil,
  43. on_rate_limit: nil,
  44. on_error: nil,
  45. operation: nil,
  46. loggable: nil,
  47. user: nil,
  48. error_context: nil
  49. )
  50. @provider_chain = provider_chain
  51. @prompt = prompt
  52. @content_size = content_size
  53. @system_message = system_message
  54. @provider_for = provider_for
  55. @logger_builder = logger_builder
  56. @run_options = run_options
  57. @on_exception = on_exception
  58. @on_rate_limit = on_rate_limit
  59. @on_error = on_error
  60. @operation = operation
  61. @loggable = loggable
  62. @user = user
  63. @error_context = error_context
  64. end
  65. # Runs providers in order until one is accepted.
  66. #
  67. # @yieldparam response [Hash] Provider response
  68. # @yieldreturn [Array] [parsed, log_data, accept]
  69. # @return [Hash] Result with status, parsed, log id, and metadata
  70. def run
  71. provider_chain.each do |provider_name|
  72. provider = provider_for.call(provider_name)
  73. next unless provider
  74. unless provider.available?
  75. log_unavailable_provider(provider_name, provider)
  76. next
  77. end
  78. response_model = nil
  79. accept = true
  80. parsed = nil
  81. logger = logger_builder.call(provider_name, provider)
  82. result = logger.record(prompt: prompt, content_size: content_size) do
  83. response = provider.run(prompt, **provider_run_options)
  84. response_model = response[:model]
  85. if response[:rate_limit]
  86. on_rate_limit&.call(response, provider_name, logger)
  87. next error_log_data(
  88. response,
  89. error: "rate_limited",
  90. rate_limit: true
  91. )
  92. end
  93. if response[:error]
  94. on_error&.call(response, provider_name, logger)
  95. next error_log_data(
  96. response,
  97. error: response[:error],
  98. error_type: response[:error_type]
  99. )
  100. end
  101. parsed, log_data, accept = yield(response)
  102. log_data ||= {}
  103. log_data.merge(standard_response_data(response))
  104. end
  105. next if result[:rate_limit] || result[:error]
  106. next if accept == false
  107. model_name = response_model || (provider.respond_to?(:model_name) ? provider.model_name : "unknown")
  108. return {
  109. success: true,
  110. provider: provider_name,
  111. model: model_name,
  112. parsed: parsed,
  113. llm_api_log_id: result[:llm_api_log_id],
  114. latency_ms: result[:latency_ms],
  115. result: result
  116. }
  117. rescue StandardError => e
  118. handle_exception(e, provider_name, logger)
  119. next
  120. end
  121. { success: false, error: "All providers failed" }
  122. end
  123. private
  124. attr_reader :provider_chain,
  125. :prompt,
  126. :content_size,
  127. :system_message,
  128. :provider_for,
  129. :logger_builder,
  130. :run_options,
  131. :on_exception,
  132. :on_rate_limit,
  133. :on_error,
  134. :operation,
  135. :loggable,
  136. :user,
  137. :error_context
  138. def log_unavailable_provider(provider_name, provider)
  139. logger = logger_builder.call(provider_name, provider)
  140. logger.record_result(
  141. {
  142. error: "provider_unavailable",
  143. error_type: "configuration",
  144. provider_endpoint: (provider.respond_to?(:provider_endpoint) ? provider.provider_endpoint : nil)
  145. }.compact,
  146. latency_ms: 0,
  147. prompt: prompt,
  148. content_size: content_size
  149. )
  150. rescue StandardError => e
  151. # best-effort only; never block the runner
  152. handle_exception(e, provider_name, logger)
  153. nil
  154. end
  155. # Builds options for provider.run
  156. #
  157. # @return [Hash]
  158. def provider_run_options
  159. base = run_options.dup
  160. base[:system_message] = system_message if system_message.present?
  161. base
  162. end
  163. # Builds standard log data for error results.
  164. #
  165. # @param response [Hash]
  166. # @param error [String]
  167. # @param error_type [String, nil]
  168. # @param rate_limit [Boolean]
  169. # @return [Hash]
  170. def error_log_data(response, error:, error_type: nil, rate_limit: false)
  171. standard_response_data(response).merge(
  172. error: error,
  173. error_type: error_type,
  174. rate_limit: rate_limit
  175. )
  176. end
  177. # Builds standard log data for responses.
  178. #
  179. # @param response [Hash]
  180. # @return [Hash]
  181. def standard_response_data(response)
  182. {
  183. input_tokens: response[:input_tokens],
  184. output_tokens: response[:output_tokens],
  185. raw_response: response[:content],
  186. provider_request: response[:provider_request],
  187. provider_response: response[:provider_response],
  188. provider_error_response: response[:provider_error_response],
  189. http_status: response[:http_status],
  190. response_headers: response[:response_headers],
  191. provider_endpoint: response[:provider_endpoint]
  192. }
  193. end
  194. def handle_exception(exception, provider_name, logger)
  195. return on_exception.call(exception, provider_name, logger) if on_exception
  196. latency_ms = logger&.log&.latency_ms
  197. model_name = logger&.log&.model
  198. extra = { processing_time_ms: latency_ms }.merge(error_context.to_h).compact
  199. if operation.present?
  200. notify_ai_error(
  201. exception,
  202. operation: operation,
  203. provider: provider_name,
  204. model: model_name,
  205. loggable: loggable,
  206. severity: extra.delete(:severity) || "error",
  207. **extra
  208. )
  209. else
  210. notify_error(
  211. exception,
  212. context: "provider_runner",
  213. severity: extra.delete(:severity) || "error",
  214. user: user,
  215. **extra
  216. )
  217. end
  218. end
  219. end
  220. end

app/services/ai/response_parser_service.rb

0.0% lines covered

100.0% branches covered

22 relevant lines. 0 lines covered and 22 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Ai
  3. # Service for parsing LLM responses into JSON data.
  4. #
  5. # Supports extracting JSON blocks embedded in text and optional symbolization.
  6. #
  7. # @example
  8. # parsed = Ai::ResponseParserService.new(response_text).parse(symbolize: true)
  9. class ResponseParserService
  10. # @param response_text [String] Raw LLM response
  11. # @param json_only [Boolean] If true, only parse JSON block
  12. def initialize(response_text, json_only: true)
  13. @response_text = response_text.to_s
  14. @json_only = json_only
  15. end
  16. # Parses the response into a Hash.
  17. #
  18. # @param symbolize [Boolean] Whether to symbolize keys
  19. # @return [Hash, nil]
  20. def parse(symbolize: false)
  21. return nil if response_text.blank?
  22. payload = json_only ? extract_json(response_text) : response_text
  23. return nil if payload.blank?
  24. JSON.parse(payload, symbolize_names: symbolize)
  25. rescue JSON::ParserError
  26. nil
  27. end
  28. private
  29. attr_reader :response_text, :json_only
  30. def extract_json(text)
  31. match = text.match(/\{.*\}/m)
  32. match&.[](0)
  33. end
  34. end
  35. end

app/services/api_fetchers/base_fetcher.rb

0.0% lines covered

100.0% branches covered

70 relevant lines. 0 lines covered and 70 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module ApiFetchers
  3. # Base fetcher class for job board API integrations
  4. #
  5. # Provides common functionality for making API requests
  6. # and parsing responses.
  7. #
  8. # @abstract Subclass and override {#fetch} to implement
  9. class BaseFetcher < ApplicationService
  10. # Logs a structured event for API operations
  11. #
  12. # @param [String] event_name The event name
  13. # @param [Hash] data Additional event data
  14. def log_event(event_name, data = {})
  15. base_data = {
  16. event: event_name,
  17. service: self.class.name
  18. }
  19. Rails.logger.info(base_data.merge(data).to_json)
  20. end
  21. # Logs an error for API operations
  22. #
  23. # @param [String] message The error message
  24. # @param [Exception, nil] exception Optional exception object
  25. def log_error(message, exception = nil)
  26. error_data = {
  27. error: message,
  28. service: self.class.name
  29. }
  30. if exception
  31. error_data.merge!(
  32. exception: exception.class.name,
  33. message: exception.message,
  34. backtrace: exception.backtrace&.first(5)
  35. )
  36. end
  37. Rails.logger.error(error_data.to_json)
  38. end
  39. # Fetches job listing data from the API
  40. #
  41. # @param [String] url The job listing URL
  42. # @param [String] job_id The job ID
  43. # @param [String] company_slug The company identifier
  44. # @return [Hash] Standardized job data
  45. # @raise [NotImplementedError] Must be implemented by subclass
  46. def fetch(url:, job_id: nil, company_slug: nil)
  47. raise NotImplementedError, "#{self.class} must implement #fetch"
  48. end
  49. protected
  50. # Makes an API request with standard headers and error handling
  51. #
  52. # @param [String] url The API endpoint URL
  53. # @param [Hash] headers Additional headers
  54. # @return [HTTParty::Response] The response
  55. def make_request(url, headers: {})
  56. HTTParty.get(
  57. url,
  58. headers: default_headers.merge(headers),
  59. timeout: 30,
  60. open_timeout: 10,
  61. follow_redirects: true
  62. )
  63. rescue => e
  64. log_error("API request failed", e)
  65. raise
  66. end
  67. # Returns default headers for API requests
  68. #
  69. # @return [Hash] Default headers
  70. def default_headers
  71. {
  72. "User-Agent" => "GleaniaBot/1.0 (+https://gleania.com/bot)",
  73. "Accept" => "application/json"
  74. }
  75. end
  76. # Normalizes the API response to our standard format
  77. #
  78. # @param [Hash] api_data The raw API response
  79. # @return [Hash] Standardized job data
  80. def normalize_response(api_data)
  81. {
  82. title: api_data[:title],
  83. description: api_data[:description],
  84. requirements: api_data[:requirements],
  85. responsibilities: api_data[:responsibilities],
  86. location: api_data[:location],
  87. remote_type: api_data[:remote_type] || "on_site",
  88. salary_min: api_data[:salary_min],
  89. salary_max: api_data[:salary_max],
  90. salary_currency: api_data[:salary_currency] || "USD",
  91. equity_info: api_data[:equity_info],
  92. benefits: api_data[:benefits],
  93. perks: api_data[:perks],
  94. custom_sections: api_data[:custom_sections] || {},
  95. confidence: 1.0, # API data is high confidence
  96. extraction_method: "api",
  97. provider: provider_name
  98. }
  99. end
  100. # Returns the provider name
  101. #
  102. # @return [String] Provider name
  103. def provider_name
  104. self.class.name.demodulize.gsub("Fetcher", "").downcase
  105. end
  106. end
  107. end

app/services/api_fetchers/greenhouse_fetcher.rb

0.0% lines covered

100.0% branches covered

115 relevant lines. 0 lines covered and 115 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "cgi"
  3. module ApiFetchers
  4. # Greenhouse API fetcher for job listings
  5. #
  6. # Uses Greenhouse's public job board API to fetch job listing data.
  7. # API docs: https://developers.greenhouse.io/job-board.html
  8. class GreenhouseFetcher < BaseFetcher
  9. BASE_URL = "https://boards-api.greenhouse.io/v1/boards"
  10. # Fetches job listing from Greenhouse API
  11. #
  12. # @param [String] url The job listing URL
  13. # @param [String] job_id The Greenhouse job ID
  14. # @param [String] company_slug The company board token/slug
  15. # @return [Hash] Standardized job data
  16. def fetch(url:, job_id: nil, company_slug: nil)
  17. return nil unless Setting.greenhouse_enabled?
  18. # If we don't have required params, try to extract from URL
  19. company_slug ||= extract_company_from_url(url)
  20. job_id ||= extract_job_id_from_url(url)
  21. raise ArgumentError, "Cannot fetch without company slug" unless company_slug
  22. raise ArgumentError, "Cannot fetch without job ID" unless job_id
  23. log_event("api_extraction_started", {
  24. board_type: "greenhouse",
  25. company_slug: company_slug,
  26. job_id: job_id,
  27. url: url
  28. })
  29. api_url = "#{BASE_URL}/#{company_slug}/jobs/#{job_id}"
  30. response = make_request(api_url)
  31. if response.success?
  32. result = parse_greenhouse_response(response.parsed_response)
  33. log_event("api_extraction_succeeded", {
  34. board_type: "greenhouse",
  35. confidence: result[:confidence]
  36. })
  37. result
  38. else
  39. log_event("api_extraction_failed", {
  40. board_type: "greenhouse",
  41. error: "API request failed: #{response.code}",
  42. http_status: response.code
  43. })
  44. { error: "API request failed: #{response.code}", confidence: 0.0 }
  45. end
  46. rescue => e
  47. log_error("Greenhouse API fetch failed", e)
  48. notify_error(
  49. e,
  50. context: "greenhouse_api_fetch",
  51. severity: "error",
  52. url: url,
  53. company_slug: company_slug,
  54. job_id: job_id
  55. )
  56. { error: e.message, confidence: 0.0 }
  57. end
  58. private
  59. # Parses Greenhouse API response to our standard format
  60. #
  61. # @param [Hash] data The Greenhouse API response
  62. # @return [Hash] Standardized job data
  63. def parse_greenhouse_response(data)
  64. location_name = data.dig("location", "name")
  65. content_html = decode_html(data["content"])
  66. # Determine remote type from location
  67. remote_type = if location_name&.downcase&.include?("remote")
  68. "remote"
  69. elsif location_name&.downcase&.include?("hybrid")
  70. "hybrid"
  71. else
  72. "on_site"
  73. end
  74. normalize_response(
  75. title: data["title"],
  76. description: content_html,
  77. requirements: extract_section(content_html, "requirements"),
  78. responsibilities: extract_section(content_html, "responsibilities"),
  79. location: location_name,
  80. remote_type: remote_type,
  81. salary_min: nil, # Greenhouse doesn't always expose salary in public API
  82. salary_max: nil,
  83. salary_currency: "USD",
  84. custom_sections: build_custom_sections(data)
  85. ).merge(
  86. company: data["company_name"]
  87. )
  88. end
  89. # Strips HTML tags from content
  90. #
  91. # @param [String] html HTML content
  92. # @return [String] Plain text
  93. def strip_html(html)
  94. return nil if html.blank?
  95. Nokogiri::HTML.fragment(html.to_s).text.to_s.gsub(/\s+/, " ").strip
  96. end
  97. # Extracts a specific section from job content
  98. #
  99. # @param [String] content The full job content
  100. # @param [String] section_name The section to extract
  101. # @return [String, nil] Extracted section or nil
  102. def extract_section(content, section_name)
  103. return nil if content.blank?
  104. # Try to find section by common headers
  105. patterns = [
  106. /<h[23][^>]*>#{section_name}<\/h[23]>(.*?)(?:<h[23]|$)/mi,
  107. /<strong>#{section_name}<\/strong>(.*?)(?:<strong>|$)/mi
  108. ]
  109. patterns.each do |pattern|
  110. match = content.match(pattern)
  111. return strip_html(match[1]) if match
  112. end
  113. nil
  114. end
  115. def decode_html(text)
  116. return "" if text.blank?
  117. CGI.unescapeHTML(text.to_s)
  118. rescue
  119. text.to_s
  120. end
  121. # Builds custom sections from Greenhouse data
  122. #
  123. # @param [Hash] data The Greenhouse response
  124. # @return [Hash] Custom sections
  125. def build_custom_sections(data)
  126. sections = {}
  127. if data["departments"]&.any?
  128. sections["departments"] = data["departments"].map { |d| d["name"] }
  129. end
  130. if data["offices"]&.any?
  131. sections["offices"] = data["offices"].map { |o| o["name"] }
  132. end
  133. sections["updated_at"] = data["updated_at"] if data["updated_at"]
  134. sections["absolute_url"] = data["absolute_url"] if data["absolute_url"]
  135. sections
  136. end
  137. # Extracts company slug from URL
  138. #
  139. # @param [String] url The job listing URL
  140. # @return [String, nil] Company slug
  141. def extract_company_from_url(url)
  142. match = url.match(%r{boards\.greenhouse\.io/([^/]+)})
  143. match ? match[1] : nil
  144. end
  145. # Extracts job ID from URL
  146. #
  147. # @param [String] url The job listing URL
  148. # @return [String, nil] Job ID
  149. def extract_job_id_from_url(url)
  150. match = url.match(%r{/jobs?/(\d+)}) || url.match(/gh_jid=([^&]+)/)
  151. match ? match[1] : nil
  152. end
  153. end
  154. end

app/services/api_fetchers/lever_fetcher.rb

0.0% lines covered

100.0% branches covered

131 relevant lines. 0 lines covered and 131 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module ApiFetchers
  3. # Lever API fetcher for job listings
  4. #
  5. # Uses Lever's public postings API to fetch job listing data.
  6. # API docs: https://github.com/lever/postings-api
  7. class LeverFetcher < BaseFetcher
  8. BASE_URL = "https://api.lever.co/v0/postings"
  9. # Fetches job listing from Lever API
  10. #
  11. # @param [String] url The job listing URL
  12. # @param [String] job_id The Lever posting ID
  13. # @param [String] company_slug The company identifier
  14. # @return [Hash] Standardized job data
  15. def fetch(url:, job_id: nil, company_slug: nil)
  16. return nil unless Setting.lever_enabled?
  17. # If we don't have required params, try to extract from URL
  18. company_slug ||= extract_company_from_url(url)
  19. job_id ||= extract_job_id_from_url(url)
  20. raise ArgumentError, "Cannot fetch without company slug" unless company_slug
  21. log_event("api_extraction_started", {
  22. board_type: "lever",
  23. company_slug: company_slug,
  24. job_id: job_id,
  25. url: url
  26. })
  27. api_url = if job_id
  28. "#{BASE_URL}/#{company_slug}/#{job_id}"
  29. else
  30. # Fetch all postings and find by URL matching
  31. "#{BASE_URL}/#{company_slug}"
  32. end
  33. response = make_request(api_url)
  34. if response.success?
  35. data = response.parsed_response
  36. result = if job_id
  37. parse_lever_response(data)
  38. else
  39. # Find matching posting from list
  40. posting = data.find { |p| p["hostedUrl"] == url }
  41. posting ? parse_lever_response(posting) : { error: "Job not found", confidence: 0.0 }
  42. end
  43. if result[:error]
  44. log_event("api_extraction_failed", {
  45. board_type: "lever",
  46. error: result[:error]
  47. })
  48. else
  49. log_event("api_extraction_succeeded", {
  50. board_type: "lever",
  51. confidence: result[:confidence]
  52. })
  53. end
  54. result
  55. else
  56. log_event("api_extraction_failed", {
  57. board_type: "lever",
  58. error: "API request failed: #{response.code}",
  59. http_status: response.code
  60. })
  61. { error: "API request failed: #{response.code}", confidence: 0.0 }
  62. end
  63. rescue => e
  64. log_error("Lever API fetch failed", e)
  65. notify_error(
  66. e,
  67. context: "lever_api_fetch",
  68. severity: "error",
  69. url: url,
  70. company_slug: company_slug,
  71. job_id: job_id
  72. )
  73. { error: e.message, confidence: 0.0 }
  74. end
  75. private
  76. # Parses Lever API response to our standard format
  77. #
  78. # @param [Hash] data The Lever API response
  79. # @return [Hash] Standardized job data
  80. def parse_lever_response(data)
  81. location = data.dig("categories", "location") || data.dig("location")
  82. # Determine remote type
  83. remote_type = if data["workplaceType"] == "remote"
  84. "remote"
  85. elsif data["workplaceType"] == "hybrid"
  86. "hybrid"
  87. else
  88. "on_site"
  89. end
  90. # Combine description sections
  91. description = [
  92. data["description"],
  93. data["descriptionPlain"]
  94. ].compact.first
  95. normalize_response(
  96. title: data["text"],
  97. description: description,
  98. requirements: extract_lists(data["lists"], [ "requirements", "qualifications" ]),
  99. responsibilities: extract_lists(data["lists"], [ "responsibilities", "role" ]),
  100. location: location,
  101. remote_type: remote_type,
  102. salary_min: nil, # Lever doesn't always expose salary in public API
  103. salary_max: nil,
  104. salary_currency: "USD",
  105. custom_sections: build_custom_sections(data)
  106. )
  107. end
  108. # Extracts content from lists by matching keys
  109. #
  110. # @param [Array] lists The lists array from Lever
  111. # @param [Array] keys Keys to match
  112. # @return [String, nil] Combined list content
  113. def extract_lists(lists, keys)
  114. return nil unless lists&.any?
  115. matching = lists.select { |list|
  116. list_text = list["text"].to_s.downcase
  117. keys.any? { |key| list_text.include?(key) }
  118. }
  119. return nil if matching.empty?
  120. matching.map { |list|
  121. content = list["content"]
  122. # Clean up HTML if present
  123. content.is_a?(String) ? content.gsub(/<[^>]+>/, "\n").strip : content
  124. }.join("\n\n")
  125. end
  126. # Builds custom sections from Lever data
  127. #
  128. # @param [Hash] data The Lever response
  129. # @return [Hash] Custom sections
  130. def build_custom_sections(data)
  131. sections = {}
  132. if data["categories"]
  133. sections["team"] = data["categories"]["team"]
  134. sections["department"] = data["categories"]["department"]
  135. sections["commitment"] = data["categories"]["commitment"]
  136. end
  137. sections["apply_url"] = data["applyUrl"] if data["applyUrl"]
  138. sections["hosted_url"] = data["hostedUrl"] if data["hostedUrl"]
  139. sections["created_at"] = data["createdAt"] if data["createdAt"]
  140. # Include additional lists that we didn't categorize
  141. if data["lists"]&.any?
  142. other_lists = data["lists"].reject { |list|
  143. text = list["text"].to_s.downcase
  144. text.include?("requirement") || text.include?("responsibilit") ||
  145. text.include?("qualif") || text.include?("role")
  146. }
  147. sections["additional_info"] = other_lists.map { |list|
  148. { "title" => list["text"], "content" => list["content"] }
  149. } if other_lists.any?
  150. end
  151. sections
  152. end
  153. # Extracts company slug from URL
  154. #
  155. # @param [String] url The job listing URL
  156. # @return [String, nil] Company slug
  157. def extract_company_from_url(url)
  158. match = url.match(%r{jobs\.lever\.co/([^/]+)})
  159. match ? match[1] : nil
  160. end
  161. # Extracts job ID from URL
  162. #
  163. # @param [String] url The job listing URL
  164. # @return [String, nil] Job ID
  165. def extract_job_id_from_url(url)
  166. match = url.match(%r{jobs\.lever\.co/[^/]+/([^/\?]+)})
  167. match ? match[1] : nil
  168. end
  169. end
  170. end

app/services/application_service.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Base class for all application services.
  3. #
  4. # Provides common error notification, logging, and utility methods.
  5. #
  6. # @example Basic service
  7. # class MyService < ApplicationService
  8. # def call
  9. # # do work
  10. # rescue StandardError => e
  11. # notify_error(e, context: "my_service")
  12. # raise
  13. # end
  14. # end
  15. #
  16. # @example AI-related service
  17. # class MyAiService < ApplicationService
  18. # def call
  19. # result = call_llm_provider
  20. # rescue StandardError => e
  21. # notify_ai_error(e, operation: "my_ai_operation", provider: "openai")
  22. # raise
  23. # end
  24. # end
  25. #
  26. class ApplicationService
  27. # Notifies of a general error with context
  28. #
  29. # @param exception [Exception] The exception to report
  30. # @param context [String] Error context (e.g., 'payment', 'sync')
  31. # @param severity [String] Severity level ('error', 'warning', 'info')
  32. # @param user [User, nil] User associated with the error
  33. # @param extra [Hash] Additional context
  34. # @return [void]
  35. def notify_error(exception, context:, severity: "error", user: nil, **extra)
  36. user_info = case user
  37. when User
  38. { id: user.id, email: user.email_address }
  39. when Hash
  40. user
  41. end
  42. ExceptionNotifier.notify(exception, {
  43. context: context,
  44. severity: severity,
  45. user: user_info
  46. }.merge(extra).compact)
  47. end
  48. # Notifies of an AI-related error with AI-specific context
  49. #
  50. # @param exception [Exception] The exception to report
  51. # @param operation [String, Symbol] AI operation type
  52. # @param provider [String, nil] LLM provider name
  53. # @param model [String, nil] Model identifier
  54. # @param loggable [ApplicationRecord, nil] The record being processed
  55. # @param severity [String] Severity level
  56. # @param extra [Hash] Additional context
  57. # @return [void]
  58. def notify_ai_error(exception, operation:, provider: nil, model: nil, loggable: nil, severity: "error", **extra)
  59. ai_context = {
  60. operation: operation.to_s,
  61. provider_name: provider,
  62. model_identifier: model,
  63. analyzable_type: loggable&.class&.name,
  64. analyzable_id: loggable&.id,
  65. severity: severity
  66. }.merge(extra.to_h).compact
  67. ExceptionNotifier.notify_ai_error(exception, ai_context)
  68. end
  69. # Logs a warning message with service context
  70. #
  71. # @param message [String] The warning message
  72. # @return [void]
  73. def log_warning(message)
  74. Rails.logger.warn("[#{self.class.name}] #{message}")
  75. end
  76. # Logs an error message with service context
  77. #
  78. # @param message [String] The error message
  79. # @return [void]
  80. def log_error(message)
  81. Rails.logger.error("[#{self.class.name}] #{message}")
  82. end
  83. # Logs an info message with service context
  84. #
  85. # @param message [String] The info message
  86. # @return [void]
  87. def log_info(message)
  88. Rails.logger.info("[#{self.class.name}] #{message}")
  89. end
  90. # Safely executes a block, catching and logging errors without re-raising
  91. #
  92. # @param fallback [Object] Value to return if block fails
  93. # @param context [String, nil] Error context for notification (if provided, error is notified)
  94. # @yield The block to execute
  95. # @return [Object] Block result or fallback value
  96. def safely(fallback: nil, context: nil)
  97. yield
  98. rescue StandardError => e
  99. log_warning("#{e.class}: #{e.message}")
  100. notify_error(e, context: context) if context
  101. fallback
  102. end
  103. # Class-level call method for convenient invocation
  104. #
  105. # @example
  106. # MyService.call(arg1, arg2)
  107. # # equivalent to: MyService.new(arg1, arg2).call
  108. #
  109. def self.call(...)
  110. new(...).call
  111. end
  112. end

app/services/application_timeline_service.rb

0.0% lines covered

100.0% branches covered

158 relevant lines. 0 lines covered and 158 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for generating timeline data for an interview application
  3. #
  4. # @example
  5. # service = ApplicationTimelineService.new(interview_application)
  6. # timeline = service.generate
  7. #
  8. class ApplicationTimelineService
  9. # Initialize the service with an interview application
  10. #
  11. # @param [InterviewApplication] interview_application The application to generate timeline for
  12. def initialize(interview_application)
  13. @application = interview_application
  14. end
  15. # Generates timeline data for the application
  16. #
  17. # @return [Array<Hash>] Array of timeline events
  18. def generate
  19. events = []
  20. # Add application submission event
  21. events << application_event
  22. # Add interview round events
  23. events.concat(interview_round_events)
  24. # Add email events
  25. events.concat(email_events)
  26. # Add company feedback event
  27. events << company_feedback_event if @application.company_feedback.present?
  28. # Add status change events (if we track them in the future)
  29. # events.concat(status_change_events)
  30. # Sort by date
  31. events.sort_by { |e| e[:date] || Time.current }
  32. end
  33. # Returns timeline as grouped by month
  34. #
  35. # @return [Hash] Timeline events grouped by month
  36. def generate_grouped
  37. generate.group_by { |event| event[:date].beginning_of_month }
  38. end
  39. # Returns summary statistics for the timeline
  40. #
  41. # @return [Hash] Summary statistics
  42. def summary
  43. {
  44. total_events: generate.count,
  45. total_rounds: @application.interview_rounds.count,
  46. completed_rounds: @application.completed_rounds_count,
  47. pending_rounds: @application.pending_rounds_count,
  48. total_emails: @application.synced_emails.count,
  49. days_since_application: days_since_application,
  50. has_feedback: @application.has_company_feedback?
  51. }
  52. end
  53. private
  54. def application_event
  55. {
  56. type: :application,
  57. title: "Applied to #{@application.job_role.title}",
  58. description: "Submitted application to #{@application.company.name}",
  59. date: @application.applied_at || @application.created_at,
  60. icon: :document,
  61. color: :blue
  62. }
  63. end
  64. def interview_round_events
  65. @application.interview_rounds.ordered.map do |round|
  66. {
  67. type: :interview_round,
  68. title: round.stage_display_name,
  69. description: round.interviewer_display || "Interview round",
  70. date: round.completed_at || round.scheduled_at || round.created_at,
  71. icon: interview_icon(round),
  72. color: interview_color(round),
  73. status: round.result,
  74. round_id: round.id
  75. }
  76. end
  77. end
  78. def company_feedback_event
  79. feedback = @application.company_feedback
  80. {
  81. type: :company_feedback,
  82. title: feedback.rejection? ? "Received Rejection" : "Received Feedback",
  83. description: feedback.summary.truncate(100),
  84. date: feedback.received_at || feedback.created_at,
  85. icon: :chat,
  86. color: feedback.rejection? ? :red : :green,
  87. feedback_id: feedback.id
  88. }
  89. end
  90. def email_events
  91. @application.synced_emails.chronological.map do |email|
  92. {
  93. type: :email,
  94. title: email_event_title(email),
  95. description: email.short_subject(80),
  96. date: email.email_date || email.created_at,
  97. icon: email_icon(email),
  98. color: email_color(email),
  99. email_id: email.id,
  100. email_type: email.email_type,
  101. from: email.sender_display,
  102. snippet: email.snippet&.truncate(150),
  103. expandable: true
  104. }
  105. end
  106. end
  107. def email_event_title(email)
  108. case email.email_type
  109. when "interview_invite"
  110. "Interview Invitation"
  111. when "scheduling"
  112. "Scheduling Request"
  113. when "application_confirmation"
  114. "Application Confirmed"
  115. when "rejection"
  116. "Application Update"
  117. when "offer"
  118. "Offer Received"
  119. when "assessment"
  120. "Assessment Request"
  121. when "follow_up"
  122. "Follow Up"
  123. when "thank_you"
  124. "Thank You Note"
  125. else
  126. "Email from #{email.sender_display}"
  127. end
  128. end
  129. def email_icon(email)
  130. case email.email_type
  131. when "interview_invite", "scheduling"
  132. :calendar
  133. when "application_confirmation"
  134. :check_circle
  135. when "rejection"
  136. :x_circle
  137. when "offer"
  138. :gift
  139. when "assessment"
  140. :clipboard
  141. when "follow_up", "thank_you"
  142. :mail
  143. else
  144. :mail
  145. end
  146. end
  147. def email_color(email)
  148. case email.email_type
  149. when "interview_invite", "scheduling"
  150. :blue
  151. when "application_confirmation"
  152. :purple
  153. when "rejection"
  154. :red
  155. when "offer"
  156. :green
  157. when "assessment"
  158. :yellow
  159. else
  160. :gray
  161. end
  162. end
  163. def interview_icon(round)
  164. case round.stage.to_sym
  165. when :screening then :phone
  166. when :technical then :code
  167. when :hiring_manager then :user
  168. when :culture_fit then :users
  169. else :calendar
  170. end
  171. end
  172. def interview_color(round)
  173. case round.result.to_sym
  174. when :passed then :green
  175. when :failed then :red
  176. when :waitlisted then :yellow
  177. else :gray
  178. end
  179. end
  180. def days_since_application
  181. return 0 unless @application.applied_at
  182. (Time.current.to_date - @application.applied_at.to_date).to_i
  183. end
  184. end

app/services/billing/admin_access_service.rb

0.0% lines covered

100.0% branches covered

61 relevant lines. 0 lines covered and 61 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Grants/revokes internal Admin/Developer access (all features enabled).
  4. #
  5. # Implementation notes:
  6. # - Entitlement grants require an expires_at, so "no restriction" is represented
  7. # as a far-future expiry.
  8. # - The grant entitlements are expanded to include all known Billing::Feature keys,
  9. # so the gating layer does not need wildcard logic.
  10. class AdminAccessService
  11. FAR_FUTURE_EXPIRY = 100.years
  12. # @param user [User]
  13. # @param actor [User, nil]
  14. def initialize(user:, actor: nil)
  15. @user = user
  16. @actor = actor
  17. end
  18. # @return [Billing::EntitlementGrant]
  19. def grant!
  20. existing = active_admin_grant
  21. if existing.present?
  22. # Keep grants up to date as features evolve. Older grants may not include
  23. # recently-added features, which would incorrectly block access.
  24. desired_entitlements = build_all_entitlements
  25. desired_expiry = Time.current + FAR_FUTURE_EXPIRY
  26. if existing.entitlements != desired_entitlements || existing.expires_at < desired_expiry
  27. existing.update!(
  28. entitlements: desired_entitlements,
  29. expires_at: desired_expiry
  30. )
  31. end
  32. return existing
  33. end
  34. Billing::EntitlementGrant.create!(
  35. user: user,
  36. source: "admin",
  37. reason: "admin_developer",
  38. starts_at: Time.current,
  39. expires_at: Time.current + FAR_FUTURE_EXPIRY,
  40. entitlements: build_all_entitlements,
  41. metadata: {
  42. granted_by_user_id: actor&.id,
  43. granted_by_email: actor&.email_address
  44. }.compact
  45. )
  46. end
  47. # @return [Integer] number of grants revoked
  48. def revoke!
  49. grants = Billing::EntitlementGrant.active_at(Time.current).where(user: user, source: "admin", reason: "admin_developer")
  50. now = Time.current
  51. grants.each do |g|
  52. # Keep the window valid: expires_at must remain > starts_at.
  53. min_expiry = g.starts_at + 1.second
  54. g.update!(expires_at: [ now, min_expiry ].max)
  55. end
  56. grants.size
  57. end
  58. # @return [Boolean]
  59. def active?
  60. active_admin_grant.present?
  61. end
  62. private
  63. attr_reader :user, :actor
  64. def active_admin_grant
  65. Billing::EntitlementGrant.active_at(Time.current).find_by(user: user, source: "admin", reason: "admin_developer")
  66. end
  67. def build_all_entitlements
  68. Billing::Feature.all.each_with_object({}) do |feature, h|
  69. # For quota features, set limit to nil (unlimited)
  70. # This explicitly overrides any base plan limits
  71. if feature.kind == "quota"
  72. h[feature.key] = { "enabled" => true, "limit" => nil }
  73. else
  74. h[feature.key] = { "enabled" => true }
  75. end
  76. end
  77. end
  78. end
  79. end

app/services/billing/catalog.rb

0.0% lines covered

100.0% branches covered

21 relevant lines. 0 lines covered and 21 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Read-only access layer for the billing catalog (plans/features/entitlements).
  4. #
  5. # This is intentionally cached because it is used by both in-app surfaces and
  6. # public pricing pages. All catalog models purge this cache on commit so
  7. # changes in the developer portal reflect immediately.
  8. class Catalog
  9. CACHE_KEY = "billing:catalog:v1"
  10. CACHE_TTL = ENV.fetch("BILLING_CATALOG_CACHE_TTL", 15.seconds).to_i
  11. class << self
  12. # Returns published plans ordered for display, including entitlements.
  13. #
  14. # @return [Array<Billing::Plan>]
  15. def published_plans
  16. cached[:published_plans]
  17. end
  18. # Purges cached catalog data.
  19. #
  20. # @return [void]
  21. def purge_cache!
  22. Rails.cache.delete(CACHE_KEY)
  23. end
  24. private
  25. def cached
  26. Rails.cache.fetch(CACHE_KEY, expires_in: CACHE_TTL) do
  27. plans = Billing::Plan.published.ordered.includes(plan_entitlements: :feature).to_a
  28. { published_plans: plans }
  29. end
  30. end
  31. end
  32. end
  33. end

app/services/billing/debug_snapshot_service.rb

0.0% lines covered

100.0% branches covered

124 relevant lines. 0 lines covered and 124 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Builds a debug snapshot of billing/subscription state for a user.
  4. # Intended for internal support/debug UI (developer portal).
  5. #
  6. # @example
  7. # snapshot = Billing::DebugSnapshotService.new(user: Current.user).run
  8. #
  9. class DebugSnapshotService
  10. # @param user [User]
  11. # @param at [Time]
  12. def initialize(user:, at: Time.current)
  13. @user = user
  14. @at = at
  15. end
  16. # @return [Hash]
  17. def run
  18. ent = Billing::Entitlements.for(user, at: at)
  19. subscription = ent.active_subscription
  20. grants = Billing::EntitlementGrant.active_at(at).where(user: user).order(created_at: :asc).to_a
  21. {
  22. generated_at: at.iso8601,
  23. user: {
  24. id: user.id,
  25. uuid: user.uuid,
  26. email: user.email_address,
  27. legacy_is_admin_flag: (user.respond_to?(:is_admin) ? (user.is_admin == true) : nil)
  28. },
  29. plans: {
  30. subscription_plan: plan_summary(ent.subscription_plan),
  31. effective_plan: plan_summary(ent.effective_plan)
  32. },
  33. subscription: subscription_summary(subscription),
  34. grants: grants.map { |g| grant_summary(g) },
  35. entitlements: entitlements_summary(ent)
  36. }
  37. end
  38. private
  39. attr_reader :user, :at
  40. # @param plan [Billing::Plan, nil]
  41. # @return [Hash, nil]
  42. def plan_summary(plan)
  43. return nil if plan.nil?
  44. {
  45. id: plan.id,
  46. uuid: plan.uuid,
  47. key: plan.key,
  48. name: plan.name,
  49. plan_type: plan.plan_type,
  50. interval: plan.interval,
  51. amount_cents: plan.amount_cents,
  52. currency: plan.currency,
  53. published: plan.published
  54. }
  55. end
  56. # @param subscription [Billing::Subscription, nil]
  57. # @return [Hash, nil]
  58. def subscription_summary(subscription)
  59. return nil if subscription.nil?
  60. {
  61. id: subscription.id,
  62. uuid: subscription.uuid,
  63. provider: subscription.provider,
  64. status: subscription.status,
  65. plan: plan_summary(subscription.plan),
  66. current_period_starts_at: subscription.current_period_starts_at&.iso8601,
  67. current_period_ends_at: subscription.current_period_ends_at&.iso8601,
  68. trial_ends_at: subscription.trial_ends_at&.iso8601,
  69. cancel_at_period_end: subscription.cancel_at_period_end,
  70. updated_at: subscription.updated_at&.iso8601
  71. }
  72. end
  73. # @param grant [Billing::EntitlementGrant]
  74. # @return [Hash]
  75. def grant_summary(grant)
  76. entitlement_keys = grant.entitlements.is_a?(Hash) ? grant.entitlements.keys.sort : []
  77. {
  78. id: grant.id,
  79. uuid: grant.uuid,
  80. source: grant.source,
  81. reason: grant.reason,
  82. plan: plan_summary(grant.plan),
  83. starts_at: grant.starts_at&.iso8601,
  84. expires_at: grant.expires_at&.iso8601,
  85. active: grant.active?(time: at),
  86. entitlements_keys: entitlement_keys,
  87. entitlements_size: grant.entitlements.is_a?(Hash) ? grant.entitlements.size : nil,
  88. entitlements_sample: entitlements_sample(grant.entitlements)
  89. }
  90. end
  91. # @param entitlements [Object]
  92. # @return [Hash]
  93. def entitlements_sample(entitlements)
  94. return {} unless entitlements.is_a?(Hash)
  95. keys = %w[
  96. interview_prepare_access
  97. interview_prepare_refreshes
  98. round_prep_access
  99. round_prep_generations
  100. ai_summaries
  101. interviews
  102. ]
  103. entitlements.slice(*keys)
  104. end
  105. # @param ent [Billing::Entitlements]
  106. # @return [Hash]
  107. def entitlements_summary(ent)
  108. feature_keys = %w[
  109. interview_prepare_access
  110. interview_prepare_refreshes
  111. round_prep_access
  112. round_prep_generations
  113. ai_summaries
  114. interviews
  115. ]
  116. {
  117. subscription_status: ent.subscription_status,
  118. purchase_active: ent.purchase_active?,
  119. purchase_expires_at: ent.purchase_expires_at&.iso8601,
  120. insight_trial_active: ent.insight_trial_active?,
  121. insight_trial_expires_at: ent.insight_trial_expires_at&.iso8601,
  122. billing_admin_access: user.billing_admin_access?,
  123. features: feature_keys.index_with { |k| feature_debug(ent, k) }
  124. }
  125. end
  126. # @param ent [Billing::Entitlements]
  127. # @param feature_key [String]
  128. # @return [Hash]
  129. def feature_debug(ent, feature_key)
  130. feature = Billing::Feature.find_by(key: feature_key)
  131. kind = feature&.kind || "unknown"
  132. limit = ent.limit(feature_key)
  133. remaining = ent.remaining(feature_key)
  134. {
  135. kind: kind,
  136. allowed: ent.allowed?(feature_key),
  137. limit: limit,
  138. remaining: remaining,
  139. used_this_period: used_this_period(feature_key, limit: limit, remaining: remaining)
  140. }.compact
  141. end
  142. # @param feature_key [String]
  143. # @param limit [Integer, nil]
  144. # @param remaining [Integer, nil]
  145. # @return [Integer, nil]
  146. def used_this_period(feature_key, limit:, remaining:)
  147. return nil if limit.nil? || remaining.nil?
  148. limit - remaining
  149. end
  150. end
  151. end

app/services/billing/entitlements.rb

0.0% lines covered

100.0% branches covered

188 relevant lines. 0 lines covered and 188 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Computes effective entitlements for a user by combining:
  4. # - Plan entitlements (from the active subscription, or Free fallback)
  5. # - Active entitlement grants (trials/promos/admin overrides)
  6. # - Usage counters (for quota remaining)
  7. #
  8. # Usage:
  9. # ent = Billing::Entitlements.for(Current.user)
  10. # ent.allowed?(:pattern_detection)
  11. # ent.remaining(:ai_summaries)
  12. class Entitlements
  13. # Legacy plan key aliases for backwards compatibility.
  14. #
  15. # We previously used simpler keys like "pro" and "sprint". The billing catalog
  16. # now uses more explicit keys ("pro_monthly", "sprint_one_time"), but older
  17. # subscriptions/grants may still reference the legacy plan records.
  18. PLAN_KEY_ALIASES = {
  19. "pro" => "pro_monthly",
  20. "sprint" => "sprint_one_time"
  21. }.freeze
  22. class << self
  23. # @param user [User]
  24. # @param at [Time]
  25. # @return [Billing::Entitlements]
  26. def for(user, at: Time.current)
  27. new(user, at: at)
  28. end
  29. end
  30. attr_reader :user, :at
  31. # @param user [User]
  32. # @param at [Time]
  33. def initialize(user, at: Time.current)
  34. @user = user
  35. @at = at
  36. end
  37. # Returns the subscription-based plan (does not consider one-time purchases).
  38. #
  39. # @return [Billing::Plan, nil]
  40. def subscription_plan
  41. normalized_plan(active_subscription&.plan) || Billing::Plan.find_by(key: "free")
  42. end
  43. # Returns the effective plan, considering both subscriptions and one-time purchase grants.
  44. # One-time purchases (like Sprint) take precedence over subscriptions while active.
  45. #
  46. # @return [Billing::Plan, nil]
  47. def effective_plan
  48. @effective_plan ||= begin
  49. purchase_plan = normalized_plan(active_purchase_grant&.plan)
  50. purchase_plan || subscription_plan
  51. end
  52. end
  53. # Alias for backward compatibility.
  54. #
  55. # @return [Billing::Plan, nil]
  56. def plan
  57. effective_plan
  58. end
  59. # @param feature_key [String, Symbol]
  60. # @return [Boolean]
  61. def allowed?(feature_key)
  62. spec = entitlement_spec(feature_key)
  63. spec.fetch("enabled", false) == true
  64. end
  65. # @param feature_key [String, Symbol]
  66. # @return [Integer, nil]
  67. def limit(feature_key)
  68. spec = entitlement_spec(feature_key)
  69. spec["limit"]
  70. end
  71. # @param feature_key [String, Symbol]
  72. # @return [Integer, nil]
  73. def remaining(feature_key)
  74. lim = limit(feature_key)
  75. return nil if lim.nil?
  76. used = usage_for(feature_key)
  77. [ lim - used, 0 ].max
  78. end
  79. # Returns the active subscription for the user.
  80. #
  81. # @return [Billing::Subscription, nil]
  82. def active_subscription
  83. @active_subscription ||= Billing::Subscription.where(user: user).active.order(updated_at: :desc).detect { |s| s.active_at?(at: at) }
  84. end
  85. # Returns the active one-time purchase grant if present (e.g., Sprint).
  86. # Purchase grants have source="purchase" and reason starting with "one_time_purchase:".
  87. #
  88. # @return [Billing::EntitlementGrant, nil]
  89. def active_purchase_grant
  90. @active_purchase_grant ||= Billing::EntitlementGrant
  91. .where(user: user, source: "purchase")
  92. .where("reason LIKE ?", "one_time_purchase:%")
  93. .active_at(at)
  94. .order(expires_at: :desc)
  95. .first
  96. end
  97. # @return [Boolean]
  98. def purchase_active?
  99. active_purchase_grant.present?
  100. end
  101. # Returns when the one-time purchase expires.
  102. #
  103. # @return [Time, nil]
  104. def purchase_expires_at
  105. active_purchase_grant&.expires_at
  106. end
  107. # Returns the time remaining for the one-time purchase in words.
  108. #
  109. # @return [String, nil]
  110. def purchase_time_remaining_in_words
  111. return nil unless purchase_active?
  112. seconds = [ (purchase_expires_at - Time.current).to_i, 0 ].max
  113. days = seconds / 86400
  114. hours = (seconds % 86400) / 3600
  115. if days > 1
  116. "#{days} days"
  117. elsif days == 1
  118. "1 day"
  119. elsif hours > 0
  120. "#{hours} hour#{'s' if hours != 1}"
  121. else
  122. "less than an hour"
  123. end
  124. end
  125. # Returns the active insight-triggered trial grant if present.
  126. #
  127. # @return [Billing::EntitlementGrant, nil]
  128. def insight_trial_grant
  129. @insight_trial_grant ||= Billing::EntitlementGrant
  130. .where(user: user, source: "trial", reason: "insight_triggered")
  131. .active_at(at)
  132. .first
  133. end
  134. # @return [Boolean]
  135. def insight_trial_active?
  136. insight_trial_grant.present?
  137. end
  138. # @return [Time, nil]
  139. def insight_trial_expires_at
  140. insight_trial_grant&.expires_at
  141. end
  142. # Returns the time remaining in the insight trial in seconds.
  143. #
  144. # @return [Integer, nil]
  145. def insight_trial_time_remaining
  146. return nil unless insight_trial_active?
  147. [ (insight_trial_expires_at - Time.current).to_i, 0 ].max
  148. end
  149. # Returns a human-readable time remaining string.
  150. #
  151. # @return [String, nil]
  152. def insight_trial_time_remaining_in_words
  153. seconds = insight_trial_time_remaining
  154. return nil if seconds.nil?
  155. hours = seconds / 3600
  156. minutes = (seconds % 3600) / 60
  157. if hours > 0
  158. "#{hours} hour#{'s' if hours != 1}"
  159. elsif minutes > 0
  160. "#{minutes} minute#{'s' if minutes != 1}"
  161. else
  162. "less than a minute"
  163. end
  164. end
  165. # Returns the overall subscription status.
  166. #
  167. # @return [Symbol] :trial, :free, :active, :trialing, :cancelled, :past_due, :expired, :inactive
  168. def subscription_status
  169. return :trial if insight_trial_active? && active_subscription.nil?
  170. return :free if active_subscription.nil?
  171. active_subscription.status.to_sym
  172. end
  173. # Returns the next billing/renewal date.
  174. #
  175. # @return [Time, nil]
  176. def renewal_date
  177. active_subscription&.current_period_ends_at
  178. end
  179. # Returns whether the subscription is set to cancel at period end.
  180. #
  181. # @return [Boolean]
  182. def cancel_at_period_end?
  183. active_subscription&.cancel_at_period_end || false
  184. end
  185. # Returns usage data for all quota features.
  186. #
  187. # @return [Hash] { feature_key => { used: X, limit: Y, remaining: Z, name: String } }
  188. def quota_usage
  189. quota_features = Billing::Feature.where(kind: "quota")
  190. quota_features.each_with_object({}) do |feature, hash|
  191. lim = limit(feature.key)
  192. used = usage_for(feature.key)
  193. hash[feature.key] = {
  194. name: feature.name,
  195. used: used,
  196. limit: lim,
  197. remaining: lim.nil? ? nil : [ lim - used, 0 ].max,
  198. unlimited: lim.nil?
  199. }
  200. end
  201. end
  202. private
  203. def entitlement_spec(feature_key)
  204. key = feature_key.to_s
  205. merged = plan_entitlements_hash
  206. grants = active_grants
  207. # Grants override plan entitlements
  208. grants.each do |grant|
  209. grant.entitlements.each do |k, v|
  210. merged[k.to_s] = (merged[k.to_s] || {}).merge(v || {})
  211. end
  212. end
  213. spec = merged[key] || {}
  214. # Admin/Developer access is intended to grant full, unrestricted access.
  215. # Historically we expanded all features into the grant's entitlements JSON.
  216. # If a feature is added later, older grants may not include it; treat an
  217. # active admin grant as a wildcard override.
  218. if admin_access_active?(grants)
  219. feature = Billing::Feature.find_by(key: key)
  220. if feature&.kind == "quota"
  221. spec = spec.merge("enabled" => true, "limit" => nil)
  222. else
  223. spec = spec.merge("enabled" => true)
  224. end
  225. end
  226. spec
  227. end
  228. # @param plan [Billing::Plan, nil]
  229. # @return [Billing::Plan, nil]
  230. def normalized_plan(plan)
  231. return nil if plan.nil?
  232. canonical_key = PLAN_KEY_ALIASES[plan.key]
  233. return plan if canonical_key.blank?
  234. Billing::Plan.find_by(key: canonical_key) || plan
  235. end
  236. # @param grants [Array<Billing::EntitlementGrant>]
  237. # @return [Boolean]
  238. def admin_access_active?(grants)
  239. grants.any? { |g| g.source == "admin" && g.reason == "admin_developer" }
  240. end
  241. def plan_entitlements_hash
  242. p = plan
  243. return {} if p.nil?
  244. p.plan_entitlements.includes(:feature).each_with_object({}) do |ent, h|
  245. next if ent.feature.nil?
  246. h[ent.feature.key] = {
  247. "enabled" => ent.enabled == true,
  248. "limit" => ent.limit
  249. }.compact
  250. end
  251. end
  252. def active_grants
  253. Billing::EntitlementGrant.active_at(at).where(user: user).order(created_at: :asc).to_a
  254. end
  255. def usage_for(feature_key)
  256. period = usage_period
  257. counter = Billing::UsageCounter.find_by(
  258. user: user,
  259. feature_key: feature_key.to_s,
  260. period_starts_at: period[:starts_at]
  261. )
  262. counter&.used.to_i
  263. end
  264. # For v1 we use a simple calendar-month usage window.
  265. # (We can evolve this later to support per-plan windows or Sprint-style 30-day windows.)
  266. def usage_period
  267. starts_at = at.beginning_of_month
  268. ends_at = (starts_at + 1.month)
  269. { starts_at: starts_at, ends_at: ends_at }
  270. end
  271. end
  272. end

app/services/billing/plan_switcher.rb

0.0% lines covered

100.0% branches covered

97 relevant lines. 0 lines covered and 97 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Handles plan switching logic, ensuring only one active plan at a time.
  4. #
  5. # Subscription → Sprint: Cancels subscription at period end, Sprint activates immediately.
  6. # Sprint → Subscription: Deactivates Sprint grant, subscription activates immediately.
  7. #
  8. # LemonSqueezy handles proration automatically for subscription changes.
  9. class PlanSwitcher < ApplicationService
  10. attr_reader :user
  11. # @param user [User]
  12. def initialize(user)
  13. @user = user
  14. end
  15. # Prepares for switching to a new plan by cancelling conflicting plans.
  16. #
  17. # @param new_plan [Billing::Plan]
  18. # @return [Hash] { cancelled_subscription: Boolean, deactivated_grant: Boolean }
  19. def prepare_switch(new_plan)
  20. result = { cancelled_subscription: false, deactivated_grant: false }
  21. if new_plan.one_time?
  22. # Switching to one-time plan (Sprint) - cancel any active subscription
  23. result[:cancelled_subscription] = cancel_active_subscription
  24. else
  25. # Switching to subscription plan - deactivate any active one-time purchase grants
  26. result[:deactivated_grant] = deactivate_purchase_grants
  27. end
  28. result
  29. end
  30. # Cancels the user's active subscription at period end.
  31. # The user keeps access until the billing period ends, then it expires.
  32. #
  33. # @return [Boolean] true if a subscription was cancelled
  34. def cancel_active_subscription
  35. subscription = active_subscription
  36. return false if subscription.nil?
  37. # Skip if already cancelled
  38. return false if subscription.cancel_at_period_end
  39. provider = Billing::Providers::LemonSqueezy.new
  40. provider.cancel_subscription(subscription: subscription)
  41. log_info(
  42. "cancelled subscription for one-time purchase " \
  43. "user_id=#{user.id} subscription_id=#{subscription.id}"
  44. )
  45. true
  46. rescue => e
  47. notify_error(
  48. e,
  49. context: "billing",
  50. severity: "error",
  51. user: user,
  52. tags: { operation: "plan_switch", action: "cancel_subscription" },
  53. subscription_id: subscription&.id
  54. )
  55. # Don't block the checkout - subscription will remain active alongside Sprint
  56. false
  57. end
  58. # Deactivates active one-time purchase grants (e.g., Sprint).
  59. # This allows subscription features to take over.
  60. #
  61. # @return [Boolean] true if any grants were deactivated
  62. def deactivate_purchase_grants
  63. grants = active_purchase_grants
  64. return false if grants.empty?
  65. grants.each do |grant|
  66. # Set expires_at to now to deactivate
  67. grant.update!(
  68. expires_at: Time.current,
  69. metadata: grant.metadata.merge(
  70. "deactivated_reason" => "subscription_switch",
  71. "deactivated_at" => Time.current.iso8601,
  72. "original_expires_at" => grant.expires_at_was&.iso8601
  73. )
  74. )
  75. log_info(
  76. "deactivated purchase grant for subscription " \
  77. "user_id=#{user.id} grant_id=#{grant.id}"
  78. )
  79. end
  80. true
  81. rescue => e
  82. notify_error(
  83. e,
  84. context: "billing",
  85. severity: "error",
  86. user: user,
  87. tags: { operation: "plan_switch", action: "deactivate_grants" },
  88. grant_ids: grants&.map(&:id)
  89. )
  90. false
  91. end
  92. # Returns the current plan type the user is on.
  93. #
  94. # @return [Symbol] :subscription, :one_time, :free
  95. def current_plan_type
  96. return :one_time if active_purchase_grants.any?
  97. return :subscription if active_subscription.present?
  98. :free
  99. end
  100. # Returns whether switching to the given plan requires cancellation.
  101. #
  102. # @param new_plan [Billing::Plan]
  103. # @return [Boolean]
  104. def requires_cancellation?(new_plan)
  105. return false if current_plan_type == :free
  106. if new_plan.one_time?
  107. # Switching to Sprint requires cancelling subscription
  108. current_plan_type == :subscription
  109. else
  110. # Switching to subscription requires deactivating Sprint
  111. current_plan_type == :one_time
  112. end
  113. end
  114. private
  115. def active_subscription
  116. @active_subscription ||= user.billing_subscriptions
  117. .where(provider: "lemonsqueezy")
  118. .where(status: %w[active trialing])
  119. .where(cancel_at_period_end: [ false, nil ])
  120. .order(updated_at: :desc)
  121. .first
  122. end
  123. def active_purchase_grants
  124. @active_purchase_grants ||= Billing::EntitlementGrant
  125. .where(user: user, source: "purchase")
  126. .where("reason LIKE ?", "one_time_purchase:%")
  127. .where("starts_at <= ? AND expires_at > ?", Time.current, Time.current)
  128. .to_a
  129. end
  130. end
  131. end

app/services/billing/providers/lemon_squeezy.rb

0.0% lines covered

100.0% branches covered

166 relevant lines. 0 lines covered and 166 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. module Providers
  4. # LemonSqueezy payment provider implementation.
  5. #
  6. # Uses the LemonSqueezy API to create hosted checkout URLs and relies on webhooks
  7. # to sync subscription state back into the app.
  8. class LemonSqueezy < ApplicationService
  9. include HTTParty
  10. base_uri "https://api.lemonsqueezy.com/v1"
  11. # @param api_key [String, nil]
  12. def initialize(api_key: nil)
  13. @api_key = api_key.presence || Rails.application.credentials.dig(:lemonsqueezy, :api_key) || ENV["LEMONSQUEEZY_API_KEY"]
  14. end
  15. # Creates a hosted checkout URL for the given plan.
  16. #
  17. # Requires a Billing::ProviderMapping for provider=lemonsqueezy with:
  18. # - external_variant_id
  19. # - metadata["store_id"]
  20. #
  21. # @param user [User]
  22. # @param plan [Billing::Plan]
  23. # @return [String] hosted checkout URL
  24. def create_checkout(user:, plan:)
  25. mapping = Billing::ProviderMapping.find_by!(provider: "lemonsqueezy", plan: plan)
  26. store_id = Rails.application.credentials.dig(:lemonsqueezy, :store_id) || ENV["LEMONSQUEEZY_STORE_ID"] || mapping.metadata["store_id"].presence
  27. variant_id = mapping.external_variant_id.presence
  28. raise "Missing LemonSqueezy store_id in provider mapping metadata" if store_id.blank?
  29. raise "Missing LemonSqueezy external_variant_id in provider mapping" if variant_id.blank?
  30. raise "Missing LemonSqueezy API key" if api_key.blank?
  31. started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  32. body = {
  33. data: {
  34. type: "checkouts",
  35. attributes: {
  36. checkout_data: {
  37. email: user.email_address,
  38. custom: {
  39. user_id: user.id.to_s
  40. }
  41. }
  42. },
  43. relationships: {
  44. store: { data: { type: "stores", id: store_id.to_s } },
  45. variant: { data: { type: "variants", id: variant_id.to_s } }
  46. }
  47. }
  48. }
  49. response = self.class.post(
  50. "/checkouts",
  51. headers: request_headers,
  52. body: body.to_json
  53. )
  54. parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
  55. url = parsed.dig("data", "attributes", "url") ||
  56. parsed.dig("data", "attributes", "checkout_url") ||
  57. parsed.dig("data", "attributes", "checkout_url_string")
  58. duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
  59. Rails.logger.info("[billing] lemonsqueezy checkout create status=#{response.code} duration_ms=#{duration_ms} plan_key=#{plan.key} user_id=#{user.id}")
  60. raise "LemonSqueezy checkout creation failed (status=#{response.code})" unless response.code.to_i.between?(200, 299)
  61. raise "LemonSqueezy checkout URL missing from response" if url.blank?
  62. url
  63. rescue => e
  64. notify_error(
  65. e,
  66. context: "payment",
  67. severity: "error",
  68. user: user,
  69. tags: { provider: "lemonsqueezy", operation: "create_checkout" },
  70. plan_key: plan&.key
  71. )
  72. raise
  73. end
  74. # Retrieves the LemonSqueezy customer portal URL for a user.
  75. #
  76. # @param customer [Billing::Customer] the customer record with external_customer_id
  77. # @return [String] the customer portal URL
  78. def customer_portal_url(customer:)
  79. raise "Missing LemonSqueezy API key" if api_key.blank?
  80. raise "Customer external_customer_id is required" if customer&.external_customer_id.blank?
  81. started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  82. response = self.class.get(
  83. "/customers/#{customer.external_customer_id}/portal",
  84. headers: request_headers
  85. )
  86. parsed = response.parsed_response.is_a?(Hash) ? response.parsed_response : {}
  87. url = parsed.dig("data", "attributes", "urls", "customer_portal") ||
  88. parsed.dig("data", "attributes", "url")
  89. duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
  90. Rails.logger.info("[billing] lemonsqueezy customer_portal status=#{response.code} duration_ms=#{duration_ms} customer_id=#{customer.id}")
  91. raise "LemonSqueezy customer portal request failed (status=#{response.code})" unless response.code.to_i.between?(200, 299)
  92. raise "LemonSqueezy customer portal URL missing from response" if url.blank?
  93. url
  94. rescue => e
  95. notify_error(
  96. e,
  97. context: "payment",
  98. severity: "error",
  99. tags: { provider: "lemonsqueezy", operation: "customer_portal" },
  100. customer_id: customer&.id
  101. )
  102. raise
  103. end
  104. # Cancels a subscription at period end.
  105. #
  106. # @param subscription [Billing::Subscription]
  107. # @return [Boolean] true if successful
  108. def cancel_subscription(subscription:)
  109. raise "Missing LemonSqueezy API key" if api_key.blank?
  110. raise "Subscription external_subscription_id is required" if subscription&.external_subscription_id.blank?
  111. started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  112. body = {
  113. data: {
  114. type: "subscriptions",
  115. id: subscription.external_subscription_id,
  116. attributes: {
  117. cancelled: true
  118. }
  119. }
  120. }
  121. response = self.class.patch(
  122. "/subscriptions/#{subscription.external_subscription_id}",
  123. headers: request_headers,
  124. body: body.to_json
  125. )
  126. duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
  127. Rails.logger.info("[billing] lemonsqueezy cancel_subscription status=#{response.code} duration_ms=#{duration_ms} subscription_id=#{subscription.id}")
  128. unless response.code.to_i.between?(200, 299)
  129. raise "LemonSqueezy cancel subscription failed (status=#{response.code})"
  130. end
  131. # Update local record optimistically (webhook will confirm)
  132. subscription.update!(cancel_at_period_end: true)
  133. true
  134. rescue => e
  135. notify_error(
  136. e,
  137. context: "payment",
  138. severity: "error",
  139. tags: { provider: "lemonsqueezy", operation: "cancel_subscription" },
  140. subscription_id: subscription&.id
  141. )
  142. raise
  143. end
  144. # Resumes a cancelled subscription (removes cancellation).
  145. #
  146. # @param subscription [Billing::Subscription]
  147. # @return [Boolean] true if successful
  148. def resume_subscription(subscription:)
  149. raise "Missing LemonSqueezy API key" if api_key.blank?
  150. raise "Subscription external_subscription_id is required" if subscription&.external_subscription_id.blank?
  151. started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  152. body = {
  153. data: {
  154. type: "subscriptions",
  155. id: subscription.external_subscription_id,
  156. attributes: {
  157. cancelled: false
  158. }
  159. }
  160. }
  161. response = self.class.patch(
  162. "/subscriptions/#{subscription.external_subscription_id}",
  163. headers: request_headers,
  164. body: body.to_json
  165. )
  166. duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
  167. Rails.logger.info("[billing] lemonsqueezy resume_subscription status=#{response.code} duration_ms=#{duration_ms} subscription_id=#{subscription.id}")
  168. unless response.code.to_i.between?(200, 299)
  169. raise "LemonSqueezy resume subscription failed (status=#{response.code})"
  170. end
  171. # Update local record optimistically (webhook will confirm)
  172. subscription.update!(cancel_at_period_end: false)
  173. true
  174. rescue => e
  175. notify_error(
  176. e,
  177. context: "payment",
  178. severity: "error",
  179. tags: { provider: "lemonsqueezy", operation: "resume_subscription" },
  180. subscription_id: subscription&.id
  181. )
  182. raise
  183. end
  184. private
  185. attr_reader :api_key
  186. def request_headers
  187. {
  188. "Authorization" => "Bearer #{api_key}",
  189. "Accept" => "application/vnd.api+json",
  190. "Content-Type" => "application/vnd.api+json"
  191. }
  192. end
  193. end
  194. end
  195. end

app/services/billing/seed_catalog_service.rb

0.0% lines covered

100.0% branches covered

198 relevant lines. 0 lines covered and 198 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Seeds the billing catalog (plans, features, entitlements) in an idempotent way.
  4. #
  5. # This is safe to run multiple times and in any environment.
  6. #
  7. # Usage:
  8. # Billing::SeedCatalogService.new.run!
  9. class SeedCatalogService
  10. # @return [void]
  11. def run!
  12. Billing::Plan.transaction do
  13. upsert_features!
  14. upsert_plans!
  15. upsert_plan_entitlements!
  16. end
  17. Billing::Catalog.purge_cache!
  18. end
  19. private
  20. def upsert_features!
  21. features.each do |attrs|
  22. feature = Billing::Feature.find_or_initialize_by(key: attrs.fetch(:key))
  23. feature.assign_attributes(attrs.except(:key))
  24. feature.save!
  25. end
  26. end
  27. def upsert_plans!
  28. plans.each do |attrs|
  29. plan = Billing::Plan.find_or_initialize_by(key: attrs.fetch(:key))
  30. plan.assign_attributes(attrs.except(:key))
  31. plan.save!
  32. end
  33. end
  34. def upsert_plan_entitlements!
  35. plans_by_key = Billing::Plan.where(key: plans.map { |p| p[:key] }).index_by(&:key)
  36. features_by_key = Billing::Feature.where(key: features.map { |f| f[:key] }).index_by(&:key)
  37. plan_entitlements.each do |row|
  38. plan = plans_by_key.fetch(row.fetch(:plan_key))
  39. feature = features_by_key.fetch(row.fetch(:feature_key))
  40. ent = Billing::PlanEntitlement.find_or_initialize_by(plan: plan, feature: feature)
  41. ent.enabled = row.fetch(:enabled, true)
  42. ent.limit = row[:limit]
  43. ent.save!
  44. end
  45. end
  46. def features
  47. [
  48. { key: "cv_parsing_basic", name: "CV parsing (basic)", kind: "boolean", description: "Basic CV parsing", unit: nil },
  49. { key: "cv_parsing_full", name: "CV parsing (full)", kind: "boolean", description: "Full CV intelligence extraction", unit: nil },
  50. { key: "skill_domain_extraction_limited", name: "Career signal extraction (limited)", kind: "boolean", description: "Limited skills/domains extraction", unit: nil },
  51. { key: "skill_domain_extraction_full", name: "Career signal extraction (full)", kind: "boolean", description: "Full skills/domains/seniority extraction", unit: nil },
  52. { key: "interviews", name: "Interview tracking quota", kind: "quota", description: "Number of interview rounds/applications allowed", unit: "interviews" },
  53. { key: "ai_summaries", name: "AI summaries quota", kind: "quota", description: "AI summaries and synthesis quota", unit: "summaries" },
  54. { key: "pattern_detection", name: "Pattern detection", kind: "boolean", description: "Themes over time / improvement patterns", unit: nil },
  55. { key: "cv_feedback_comparison", name: "CV ↔ interview comparison", kind: "boolean", description: "Cross-analysis between CV and interviews/feedback", unit: nil },
  56. { key: "cv_feedback_comparison_enhanced", name: "CV ↔ interview comparison (enhanced)", kind: "boolean", description: "Deeper cross-analysis for Sprint", unit: nil },
  57. { key: "assistant_access", name: "Assistant access", kind: "boolean", description: "Context-aware assistant access", unit: nil },
  58. { key: "assistant_priority", name: "Assistant priority", kind: "boolean", description: "Priority assistant depth/processing", unit: nil },
  59. { key: "background_processing_priority", name: "Priority background processing", kind: "boolean", description: "Priority background processing", unit: nil },
  60. { key: "insight_export", name: "Insight export", kind: "boolean", description: "Export insights", unit: nil },
  61. # Interview preparation (application-specific coaching)
  62. { key: "interview_prepare_access", name: "Interview prepare access", kind: "boolean", description: "Access to the Prepare tab coaching experience", unit: nil },
  63. { key: "interview_prepare_refreshes", name: "Interview prepare refresh quota", kind: "quota", description: "Prep pack refresh quota", unit: "refreshes" },
  64. # Round-specific interview prep (per-round coaching)
  65. { key: "round_prep_access", name: "Round prep access", kind: "boolean", description: "Access to round-specific interview prep", unit: nil },
  66. { key: "round_prep_generations", name: "Round prep generation quota", kind: "quota", description: "Round-specific prep generation quota", unit: "generations" },
  67. # Internal feature used by the 72-hour trial grant.
  68. { key: "pro_trial_access", name: "Pro trial access", kind: "boolean", description: "Unlocked during earned Pro trial window", unit: nil }
  69. ]
  70. end
  71. def plans
  72. [
  73. {
  74. key: "free",
  75. name: "Free — Reflect",
  76. description: "Trust & habit-building tier",
  77. plan_type: "free",
  78. interval: nil,
  79. amount_cents: 0,
  80. currency: "eur",
  81. highlighted: false,
  82. published: true,
  83. sort_order: 0,
  84. metadata: {
  85. pricing_features: [
  86. "Upload & parse CV (basic)",
  87. "Manual interview tracking (limited)",
  88. "Feedback journal (never gated)",
  89. "Basic AI summaries (low quota)",
  90. "Simple strengths & improvement tags"
  91. ]
  92. }
  93. },
  94. {
  95. key: "pro_monthly",
  96. name: "Pro — Grow",
  97. description: "Understand your professional profile — and improve it through every interview.",
  98. plan_type: "recurring",
  99. interval: "month",
  100. amount_cents: 1200,
  101. currency: "eur",
  102. highlighted: true,
  103. published: true,
  104. sort_order: 10,
  105. metadata: {
  106. pricing_features: [
  107. "Everything in Free",
  108. "Unlimited interviews & feedback entries",
  109. "Full career signal extraction",
  110. "Experience-backed insights over time",
  111. "Assistant access (fair use)",
  112. "Interview preparation access"
  113. ]
  114. }
  115. },
  116. {
  117. key: "sprint_one_time",
  118. name: "Sprint — Interview Focus",
  119. description: "A focused month of clarity while you’re actively interviewing.",
  120. plan_type: "one_time",
  121. interval: nil,
  122. amount_cents: 2500,
  123. currency: "eur",
  124. highlighted: false,
  125. published: true,
  126. sort_order: 20,
  127. metadata: {
  128. pricing_features: [
  129. "Everything in Pro",
  130. "Higher AI limits",
  131. "Deeper CV ↔ interview cross-analysis",
  132. "Faster insight refresh",
  133. "Priority background processing",
  134. "Interview preparation access"
  135. ]
  136. }
  137. },
  138. {
  139. key: "admin_developer",
  140. name: "Admin/Developer",
  141. description: "Internal plan for staff/admin access (not customer-facing).",
  142. plan_type: "free",
  143. interval: nil,
  144. amount_cents: 0,
  145. currency: "eur",
  146. highlighted: false,
  147. published: false,
  148. sort_order: 999,
  149. metadata: {}
  150. }
  151. ]
  152. end
  153. def plan_entitlements
  154. [
  155. # Free
  156. { plan_key: "free", feature_key: "cv_parsing_basic", enabled: true },
  157. { plan_key: "free", feature_key: "cv_parsing_full", enabled: false },
  158. { plan_key: "free", feature_key: "skill_domain_extraction_limited", enabled: true },
  159. { plan_key: "free", feature_key: "skill_domain_extraction_full", enabled: false },
  160. { plan_key: "free", feature_key: "interviews", enabled: true, limit: 5 },
  161. { plan_key: "free", feature_key: "ai_summaries", enabled: true, limit: 5 },
  162. { plan_key: "free", feature_key: "pattern_detection", enabled: false },
  163. { plan_key: "free", feature_key: "cv_feedback_comparison", enabled: false },
  164. { plan_key: "free", feature_key: "cv_feedback_comparison_enhanced", enabled: false },
  165. { plan_key: "free", feature_key: "assistant_access", enabled: false },
  166. { plan_key: "free", feature_key: "assistant_priority", enabled: false },
  167. { plan_key: "free", feature_key: "background_processing_priority", enabled: false },
  168. { plan_key: "free", feature_key: "insight_export", enabled: false },
  169. { plan_key: "free", feature_key: "interview_prepare_access", enabled: false },
  170. { plan_key: "free", feature_key: "interview_prepare_refreshes", enabled: true, limit: 0 },
  171. { plan_key: "free", feature_key: "round_prep_access", enabled: false },
  172. { plan_key: "free", feature_key: "round_prep_generations", enabled: true, limit: 0 },
  173. # Pro
  174. { plan_key: "pro_monthly", feature_key: "cv_parsing_basic", enabled: true },
  175. { plan_key: "pro_monthly", feature_key: "cv_parsing_full", enabled: true },
  176. { plan_key: "pro_monthly", feature_key: "skill_domain_extraction_limited", enabled: true },
  177. { plan_key: "pro_monthly", feature_key: "skill_domain_extraction_full", enabled: true },
  178. { plan_key: "pro_monthly", feature_key: "interviews", enabled: true, limit: nil },
  179. { plan_key: "pro_monthly", feature_key: "ai_summaries", enabled: true, limit: 50 },
  180. { plan_key: "pro_monthly", feature_key: "pattern_detection", enabled: true },
  181. { plan_key: "pro_monthly", feature_key: "cv_feedback_comparison", enabled: true },
  182. { plan_key: "pro_monthly", feature_key: "cv_feedback_comparison_enhanced", enabled: false },
  183. { plan_key: "pro_monthly", feature_key: "assistant_access", enabled: true },
  184. { plan_key: "pro_monthly", feature_key: "assistant_priority", enabled: false },
  185. { plan_key: "pro_monthly", feature_key: "background_processing_priority", enabled: false },
  186. { plan_key: "pro_monthly", feature_key: "insight_export", enabled: true },
  187. { plan_key: "pro_monthly", feature_key: "interview_prepare_access", enabled: true },
  188. { plan_key: "pro_monthly", feature_key: "interview_prepare_refreshes", enabled: true, limit: 10 },
  189. { plan_key: "pro_monthly", feature_key: "round_prep_access", enabled: true },
  190. { plan_key: "pro_monthly", feature_key: "round_prep_generations", enabled: true, limit: 20 },
  191. # Sprint
  192. { plan_key: "sprint_one_time", feature_key: "cv_parsing_basic", enabled: true },
  193. { plan_key: "sprint_one_time", feature_key: "cv_parsing_full", enabled: true },
  194. { plan_key: "sprint_one_time", feature_key: "skill_domain_extraction_limited", enabled: true },
  195. { plan_key: "sprint_one_time", feature_key: "skill_domain_extraction_full", enabled: true },
  196. { plan_key: "sprint_one_time", feature_key: "interviews", enabled: true, limit: nil },
  197. { plan_key: "sprint_one_time", feature_key: "ai_summaries", enabled: true, limit: 200 },
  198. { plan_key: "sprint_one_time", feature_key: "pattern_detection", enabled: true },
  199. { plan_key: "sprint_one_time", feature_key: "cv_feedback_comparison", enabled: true },
  200. { plan_key: "sprint_one_time", feature_key: "cv_feedback_comparison_enhanced", enabled: true },
  201. { plan_key: "sprint_one_time", feature_key: "assistant_access", enabled: true },
  202. { plan_key: "sprint_one_time", feature_key: "assistant_priority", enabled: true },
  203. { plan_key: "sprint_one_time", feature_key: "background_processing_priority", enabled: true },
  204. { plan_key: "sprint_one_time", feature_key: "insight_export", enabled: true },
  205. { plan_key: "sprint_one_time", feature_key: "interview_prepare_access", enabled: true },
  206. { plan_key: "sprint_one_time", feature_key: "interview_prepare_refreshes", enabled: true, limit: 50 },
  207. { plan_key: "sprint_one_time", feature_key: "round_prep_access", enabled: true },
  208. { plan_key: "sprint_one_time", feature_key: "round_prep_generations", enabled: true, limit: 100 }
  209. ]
  210. end
  211. end
  212. end

app/services/billing/trial_unlock_service.rb

0.0% lines covered

100.0% branches covered

60 relevant lines. 0 lines covered and 60 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. # Service for unlocking an insight-triggered Pro trial for a user.
  4. #
  5. # This is provider-agnostic and implemented via Billing::EntitlementGrant
  6. # so it works with LemonSqueezy today and other providers later.
  7. #
  8. # Eligibility:
  9. # - Once per user lifetime
  10. # - Only if user is not already on an active paid subscription
  11. #
  12. # @example
  13. # result = Billing::TrialUnlockService.new(user: Current.user, trigger: :first_feedback_after_cv).run
  14. # result[:unlocked] # => true/false
  15. class TrialUnlockService < ApplicationService
  16. TRIAL_DURATION = 72.hours
  17. REASON = "insight_triggered"
  18. SOURCE = "trial"
  19. # Feature keys granted during the trial (cost-bounded).
  20. # These keys are used by Billing::Entitlements and can be backed by catalog
  21. # features later (developer portal managed).
  22. TRIAL_ENTITLEMENTS = {
  23. "cv_full_analysis" => { "enabled" => true },
  24. "feedback_synthesis_advanced" => { "enabled" => true },
  25. "pattern_detection" => { "enabled" => true },
  26. "assistant_access" => { "enabled" => true },
  27. "interview_prepare_access" => { "enabled" => true },
  28. "round_prep_access" => { "enabled" => true },
  29. # Quotas (absolute caps) to control AI spend during trial
  30. "ai_summaries" => { "enabled" => true, "limit" => 25 },
  31. "interview_prepare_refreshes" => { "enabled" => true, "limit" => 10 },
  32. "round_prep_generations" => { "enabled" => true, "limit" => 10 },
  33. "assistant_messages" => { "enabled" => true, "limit" => 50 }
  34. }.freeze
  35. # @param user [User]
  36. # @param trigger [Symbol, String] the trigger event name
  37. # @param metadata [Hash] optional metadata (e.g., counts, ids)
  38. def initialize(user:, trigger:, metadata: {})
  39. @user = user
  40. @trigger = trigger.to_s
  41. @metadata = metadata || {}
  42. end
  43. # Attempts to unlock the trial.
  44. #
  45. # @return [Hash] result hash: { unlocked: Boolean, grant: Billing::EntitlementGrant|nil, expires_at: Time|nil }
  46. def run
  47. return { unlocked: false, grant: nil, expires_at: nil } if user.nil?
  48. return { unlocked: false, grant: nil, expires_at: nil } if ineligible_due_to_subscription?
  49. Billing::EntitlementGrant.transaction do
  50. return { unlocked: false, grant: nil, expires_at: nil } if already_unlocked?
  51. now = Time.current
  52. grant = Billing::EntitlementGrant.create!(
  53. user: user,
  54. source: SOURCE,
  55. reason: REASON,
  56. starts_at: now,
  57. expires_at: now + TRIAL_DURATION,
  58. entitlements: TRIAL_ENTITLEMENTS,
  59. metadata: metadata.merge(trigger: trigger)
  60. )
  61. { unlocked: true, grant: grant, expires_at: grant.expires_at }
  62. end
  63. rescue => e
  64. notify_error(
  65. e,
  66. context: "payment",
  67. severity: "error",
  68. user: user,
  69. trigger: trigger,
  70. metadata: metadata
  71. )
  72. { unlocked: false, grant: nil, expires_at: nil }
  73. end
  74. private
  75. attr_reader :user, :trigger, :metadata
  76. def already_unlocked?
  77. Billing::EntitlementGrant.where(user: user, source: SOURCE, reason: REASON).exists?
  78. end
  79. def ineligible_due_to_subscription?
  80. Billing::Subscription.where(user: user).active.any? { |s| s.active_at?(at: Time.current) }
  81. end
  82. end
  83. end

app/services/billing/webhooks/lemon_squeezy_processor.rb

0.0% lines covered

100.0% branches covered

332 relevant lines. 0 lines covered and 332 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. module Webhooks
  4. # Processes LemonSqueezy webhook events and syncs subscription state.
  5. #
  6. # Payload format can vary by event type and LemonSqueezy version; we parse defensively.
  7. class LemonSqueezyProcessor < ApplicationService
  8. # @param webhook_event [Billing::WebhookEvent]
  9. def initialize(webhook_event)
  10. @webhook_event = webhook_event
  11. @payload = webhook_event.payload || {}
  12. end
  13. # @return [void]
  14. def run
  15. event_type = extract_event_type
  16. webhook_event.update!(event_type: event_type) if webhook_event.event_type.blank? && event_type.present?
  17. started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  18. handled = handle_subscription_event(event_type)
  19. duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
  20. log_info("webhook processed event_type=#{event_type} handled=#{handled} duration_ms=#{duration_ms} id=#{webhook_event.id}")
  21. webhook_event.update!(
  22. status: handled ? "processed" : "ignored",
  23. processed_at: Time.current
  24. )
  25. rescue => e
  26. notify_error(
  27. e,
  28. context: "payment",
  29. severity: "error",
  30. tags: { provider: "lemonsqueezy", operation: "webhook_process", event_type: webhook_event.event_type },
  31. webhook_event_id: webhook_event.id
  32. )
  33. webhook_event.update!(status: "failed", processed_at: Time.current, error_message: "#{e.class}: #{e.message}")
  34. end
  35. private
  36. attr_reader :webhook_event, :payload
  37. def extract_event_type
  38. payload.dig("meta", "event_name") ||
  39. payload["event_name"] ||
  40. payload["type"] ||
  41. payload.dig("meta", "event") ||
  42. payload.dig("meta", "name")
  43. end
  44. def handle_subscription_event(event_type)
  45. return false if event_type.blank?
  46. normalized = event_type.to_s.downcase
  47. return handle_order_event if normalized.include?("order")
  48. return handle_subscription_invoice_event if subscription_invoice_event?(normalized)
  49. return false unless normalized.include?("subscription")
  50. handle_subscription_payload
  51. end
  52. # @return [Boolean]
  53. def handle_subscription_payload
  54. subscription_data = payload["data"] || payload.dig("data", "data") || {}
  55. attributes = subscription_data["attributes"] || payload.dig("data", "attributes") || {}
  56. subscription_id = subscription_data["id"] || payload.dig("data", "id") || attributes["subscription_id"]
  57. return false if subscription_id.blank?
  58. user = resolve_user(attributes)
  59. return false if user.nil?
  60. plan = resolve_plan(attributes)
  61. urls = extract_subscription_urls(attributes)
  62. subscription = Billing::Subscription.find_or_initialize_by(
  63. provider: "lemonsqueezy",
  64. external_subscription_id: subscription_id.to_s,
  65. user: user
  66. )
  67. subscription.plan = plan if plan.present?
  68. subscription.status = normalize_status(attributes["status"] || attributes["state"] || subscription.status)
  69. subscription.trial_ends_at = parse_time(attributes["trial_ends_at"] || attributes["trial_end_date"] || attributes["trial_end"])
  70. subscription.current_period_starts_at = parse_time(attributes["current_period_start"] || attributes["current_period_starts_at"] || attributes["renews_at"])
  71. subscription.current_period_ends_at = parse_time(attributes["current_period_end"] || attributes["current_period_ends_at"] || attributes["ends_at"] || attributes["renews_at"])
  72. subscription.cancel_at_period_end = truthy?(attributes["cancel_at_period_end"] || attributes["cancel_at_end"] || false)
  73. subscription.cancelled_at = parse_time(attributes["cancelled_at"])
  74. # Store payment method details
  75. subscription.card_brand = attributes["card_brand"] if attributes["card_brand"].present?
  76. subscription.card_last_four = attributes["card_last_four"] if attributes["card_last_four"].present?
  77. metadata = subscription.metadata || {}
  78. metadata["raw"] = attributes
  79. metadata["order_id"] = attributes["order_id"] if attributes["order_id"].present?
  80. metadata["customer_id"] = attributes["customer_id"] if attributes["customer_id"].present?
  81. subscription.metadata = metadata
  82. apply_subscription_urls(subscription, urls)
  83. subscription.save!
  84. customer = sync_customer_mapping(user: user, attributes: attributes, urls: urls)
  85. link_order_to_subscription(subscription, customer, attributes["order_id"])
  86. # Deactivate any active one-time purchase grants when subscription activates
  87. # This ensures only one plan is active at a time
  88. if subscription.status == "active"
  89. deactivate_purchase_grants_for_subscription(user, subscription)
  90. end
  91. true
  92. end
  93. # @return [Boolean]
  94. def handle_subscription_invoice_event
  95. invoice_data = payload["data"] || payload.dig("data", "data") || {}
  96. attributes = invoice_data["attributes"] || payload.dig("data", "attributes") || {}
  97. subscription_id = attributes["subscription_id"] || invoice_data["subscription_id"]
  98. return false if subscription_id.blank?
  99. subscription = Billing::Subscription.find_by(provider: "lemonsqueezy", external_subscription_id: subscription_id.to_s)
  100. return false if subscription.nil?
  101. invoice_url = attributes.dig("urls", "invoice_url")
  102. return false if invoice_url.blank?
  103. metadata = subscription.metadata || {}
  104. metadata["latest_invoice_id"] = invoice_data["id"] || attributes["id"]
  105. metadata["latest_invoice_status"] = attributes["status"]
  106. metadata["latest_invoice_total"] = attributes["total"]
  107. metadata["latest_invoice_currency"] = attributes["currency"]
  108. subscription.latest_invoice_url = invoice_url
  109. subscription.update!(metadata: metadata)
  110. true
  111. end
  112. # @return [Boolean]
  113. def handle_order_event
  114. order_data = payload["data"] || payload.dig("data", "data") || {}
  115. attributes = order_data["attributes"] || payload.dig("data", "attributes") || {}
  116. user = resolve_user(attributes)
  117. return false if user.nil?
  118. order_id = order_data["id"] || attributes["id"]
  119. return false if order_id.blank?
  120. receipt_url = attributes.dig("urls", "receipt")
  121. plan = resolve_plan(attributes)
  122. subscription = resolve_subscription_for_order(user, attributes)
  123. customer = sync_customer_mapping(user: user, attributes: attributes)
  124. order = Billing::Order.find_or_initialize_by(provider: "lemonsqueezy", external_order_id: order_id.to_s)
  125. order.user = user
  126. order.customer = customer
  127. order.subscription = subscription
  128. order.status = attributes["status"]
  129. order.total_cents = attributes["total"]
  130. order.currency = attributes["currency"]&.downcase
  131. order.order_number = attributes["order_number"]&.to_s
  132. order.identifier = attributes["identifier"]&.to_s
  133. order.receipt_url = receipt_url
  134. order.metadata = (order.metadata || {}).merge(raw: attributes)
  135. order.save!
  136. if receipt_url.present?
  137. customer.latest_receipt_url = receipt_url if customer.present?
  138. customer.save! if customer&.changed?
  139. update_subscription_receipt(subscription, order) if subscription.present?
  140. end
  141. # For one-time purchases, grant entitlements and cancel any active subscriptions
  142. if plan&.one_time?
  143. grant_one_time_entitlements(user: user, plan: plan, order: order)
  144. cancel_subscription_for_one_time_purchase(user, plan, order)
  145. end
  146. true
  147. end
  148. def resolve_user(attributes)
  149. user_id = dig_custom_user_id(attributes)
  150. return User.find_by(id: user_id) if user_id.present?
  151. email = attributes["user_email"] || attributes["email"] || attributes.dig("checkout_data", "email")
  152. return User.find_by(email_address: email.to_s.downcase) if email.present?
  153. nil
  154. end
  155. def dig_custom_user_id(attributes)
  156. attributes.dig("checkout_data", "custom", "user_id") ||
  157. attributes.dig("checkout_data", "custom_data", "user_id") ||
  158. attributes.dig("custom", "user_id") ||
  159. attributes.dig("custom_data", "user_id") ||
  160. payload.dig("meta", "custom_data", "user_id") ||
  161. payload.dig("meta", "custom", "user_id")
  162. end
  163. def resolve_plan(attributes)
  164. variant_id = attributes["variant_id"] ||
  165. attributes.dig("first_order_item", "variant_id") ||
  166. attributes.dig("variant", "id") ||
  167. attributes.dig("variant", "data", "id") ||
  168. payload.dig("data", "relationships", "variant", "data", "id")
  169. return nil if variant_id.blank?
  170. mapping = Billing::ProviderMapping.find_by(provider: "lemonsqueezy", external_variant_id: variant_id.to_s)
  171. mapping&.plan
  172. end
  173. def normalize_status(raw)
  174. value = raw.to_s.downcase
  175. return "inactive" if value.blank?
  176. case value
  177. when "active" then "active"
  178. when "trialing", "on_trial" then "trialing"
  179. when "cancelled", "canceled" then "cancelled"
  180. when "expired" then "expired"
  181. when "past_due" then "past_due"
  182. else
  183. "inactive"
  184. end
  185. end
  186. def parse_time(value)
  187. return nil if value.blank?
  188. return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
  189. Time.zone.parse(value.to_s)
  190. rescue ArgumentError, TypeError
  191. nil
  192. end
  193. def truthy?(value)
  194. value == true || value.to_s == "true" || value.to_s == "1"
  195. end
  196. # @param normalized [String]
  197. # @return [Boolean]
  198. def subscription_invoice_event?(normalized)
  199. return true if normalized.include?("subscription_payment")
  200. return true if normalized.include?("subscription_invoice")
  201. payload_type = payload.dig("data", "type") || payload.dig("data", "data", "type")
  202. payload_type.to_s == "subscription-invoices"
  203. end
  204. # @param attributes [Hash]
  205. # @return [Hash]
  206. def extract_subscription_urls(attributes)
  207. urls = attributes["urls"] || {}
  208. {
  209. "customer_portal_url" => urls["customer_portal"],
  210. "update_payment_method_url" => urls["update_payment_method"],
  211. "update_subscription_url" => urls["customer_portal_update_subscription"]
  212. }.compact
  213. end
  214. # @param user [User]
  215. # @param attributes [Hash]
  216. # @param urls [Hash, nil]
  217. # @return [Billing::Customer, nil]
  218. def sync_customer_mapping(user:, attributes:, urls: nil)
  219. external_customer_id = attributes["customer_id"] || attributes["customer"] || payload.dig("meta", "customer_id")
  220. return if external_customer_id.blank?
  221. customer = Billing::Customer.find_or_create_by!(user: user, provider: "lemonsqueezy") do |c|
  222. c.external_customer_id = external_customer_id.to_s
  223. end
  224. if customer.external_customer_id != external_customer_id.to_s
  225. customer.update!(external_customer_id: external_customer_id.to_s)
  226. end
  227. if urls.present? && urls["customer_portal_url"].present?
  228. customer.customer_portal_url = urls["customer_portal_url"]
  229. customer.save! if customer.changed?
  230. end
  231. customer
  232. end
  233. # @param order_id [String, Integer, nil]
  234. # @param attributes [Hash]
  235. # @param receipt_url [String, nil]
  236. # @return [Hash]
  237. # @param subscription [Billing::Subscription]
  238. # @param urls [Hash]
  239. # @return [void]
  240. def apply_subscription_urls(subscription, urls)
  241. return if urls.blank?
  242. subscription.assign_attributes(urls)
  243. end
  244. # @param user [User]
  245. # @param attributes [Hash]
  246. # @return [Billing::Subscription, nil]
  247. def resolve_subscription_for_order(user, attributes)
  248. plan = resolve_plan(attributes)
  249. return user.billing_subscriptions.where(provider: "lemonsqueezy").order(updated_at: :desc).first if plan.blank?
  250. user.billing_subscriptions.find_by(provider: "lemonsqueezy", plan: plan)
  251. end
  252. # @param subscription [Billing::Subscription]
  253. # @param order [Billing::Order]
  254. # @return [void]
  255. def update_subscription_receipt(subscription, order)
  256. subscription.latest_receipt_url = order.receipt_url
  257. metadata = subscription.metadata || {}
  258. metadata["latest_order_id"] = order.external_order_id
  259. metadata["latest_order_number"] = order.order_number
  260. metadata["latest_order_identifier"] = order.identifier
  261. metadata["latest_order_status"] = order.status
  262. metadata["latest_order_total"] = order.total_cents
  263. metadata["latest_order_currency"] = order.currency
  264. subscription.update!(metadata: metadata)
  265. end
  266. # @param subscription [Billing::Subscription]
  267. # @param customer [Billing::Customer, nil]
  268. # @param order_id [String, Integer, nil]
  269. # @return [void]
  270. def link_order_to_subscription(subscription, customer, order_id)
  271. return if order_id.blank?
  272. order = Billing::Order.find_by(provider: "lemonsqueezy", external_order_id: order_id.to_s)
  273. return if order.nil?
  274. updates = {}
  275. updates[:billing_subscription_id] = subscription.id if order.billing_subscription_id != subscription.id
  276. updates[:billing_customer_id] = customer.id if customer.present? && order.billing_customer_id != customer.id
  277. order.update!(updates) if updates.any?
  278. end
  279. # Creates an EntitlementGrant for one-time purchases (e.g., Sprint plan).
  280. #
  281. # @param user [User]
  282. # @param plan [Billing::Plan]
  283. # @param order [Billing::Order]
  284. # @return [Billing::EntitlementGrant, nil]
  285. def grant_one_time_entitlements(user:, plan:, order:)
  286. return unless plan&.one_time?
  287. # Check for existing grant from the same order to avoid duplicates
  288. existing = Billing::EntitlementGrant.find_by(
  289. user: user,
  290. source: "purchase",
  291. reason: "one_time_purchase:#{order.external_order_id}"
  292. )
  293. return existing if existing.present?
  294. # Duration from plan metadata or default 30 days
  295. duration_days = plan.metadata&.dig("duration_days")&.to_i
  296. duration_days = 30 if duration_days.nil? || duration_days <= 0
  297. starts_at = Time.current
  298. expires_at = starts_at + duration_days.days
  299. # Build entitlements map from plan entitlements
  300. entitlements = build_entitlements_from_plan(plan)
  301. Billing::EntitlementGrant.create!(
  302. user: user,
  303. plan: plan,
  304. source: "purchase",
  305. reason: "one_time_purchase:#{order.external_order_id}",
  306. starts_at: starts_at,
  307. expires_at: expires_at,
  308. entitlements: entitlements,
  309. metadata: {
  310. plan_key: plan.key,
  311. plan_name: plan.name,
  312. order_id: order.external_order_id,
  313. order_number: order.order_number,
  314. amount_cents: order.total_cents,
  315. currency: order.currency
  316. }
  317. )
  318. end
  319. # Builds entitlements hash from plan's PlanEntitlements.
  320. #
  321. # @param plan [Billing::Plan]
  322. # @return [Hash] Entitlements map keyed by feature_key
  323. def build_entitlements_from_plan(plan)
  324. plan.plan_entitlements.includes(:feature).each_with_object({}) do |pe, hash|
  325. next unless pe.enabled
  326. entry = { "enabled" => true }
  327. entry["limit"] = pe.limit if pe.limit.present?
  328. hash[pe.feature.key] = entry
  329. end
  330. end
  331. # Deactivates active one-time purchase grants when a subscription is created/activated.
  332. # This ensures only one plan is active at a time.
  333. #
  334. # @param user [User]
  335. # @param subscription [Billing::Subscription]
  336. # @return [void]
  337. def deactivate_purchase_grants_for_subscription(user, subscription)
  338. grants = Billing::EntitlementGrant
  339. .where(user: user, source: "purchase")
  340. .where("reason LIKE ?", "one_time_purchase:%")
  341. .where("starts_at <= ? AND expires_at > ?", Time.current, Time.current)
  342. return if grants.empty?
  343. grants.find_each do |grant|
  344. grant.update!(
  345. expires_at: Time.current,
  346. metadata: grant.metadata.merge(
  347. "deactivated_reason" => "subscription_activated",
  348. "deactivated_at" => Time.current.iso8601,
  349. "original_expires_at" => grant.expires_at_was&.iso8601,
  350. "subscription_id" => subscription.id
  351. )
  352. )
  353. log_info(
  354. "deactivated purchase grant for subscription " \
  355. "user_id=#{user.id} grant_id=#{grant.id} subscription_id=#{subscription.id}"
  356. )
  357. end
  358. end
  359. # Cancels active subscriptions when a one-time purchase is made.
  360. # This ensures only one plan is active at a time.
  361. # Note: This is a safety net; checkout controller also cancels subscriptions.
  362. #
  363. # @param user [User]
  364. # @param plan [Billing::Plan]
  365. # @param order [Billing::Order]
  366. # @return [void]
  367. def cancel_subscription_for_one_time_purchase(user, plan, order)
  368. subscriptions = user.billing_subscriptions
  369. .where(provider: "lemonsqueezy")
  370. .where(status: %w[active trialing])
  371. .where(cancel_at_period_end: [ false, nil ])
  372. return if subscriptions.empty?
  373. provider = Billing::Providers::LemonSqueezy.new
  374. subscriptions.find_each do |subscription|
  375. begin
  376. provider.cancel_subscription(subscription: subscription)
  377. log_info(
  378. "cancelled subscription for one-time purchase " \
  379. "user_id=#{user.id} subscription_id=#{subscription.id} order_id=#{order.id} plan=#{plan.key}"
  380. )
  381. rescue => e
  382. notify_error(
  383. e,
  384. context: "billing",
  385. severity: "error",
  386. user: user,
  387. tags: { provider: "lemonsqueezy", operation: "webhook_cancel_subscription" },
  388. subscription_id: subscription.id,
  389. order_id: order.id,
  390. plan_key: plan.key
  391. )
  392. end
  393. end
  394. end
  395. end
  396. end
  397. end

app/services/billing/webhooks/processor.rb

0.0% lines covered

100.0% branches covered

19 relevant lines. 0 lines covered and 19 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Billing
  3. module Webhooks
  4. # Routes webhook events to a provider-specific processor.
  5. class Processor
  6. # @param webhook_event [Billing::WebhookEvent]
  7. def initialize(webhook_event)
  8. @webhook_event = webhook_event
  9. end
  10. # @return [void]
  11. def run
  12. case webhook_event.provider
  13. when "lemonsqueezy"
  14. Billing::Webhooks::LemonSqueezyProcessor.new(webhook_event).run
  15. else
  16. webhook_event.update!(status: "ignored", processed_at: Time.current)
  17. end
  18. end
  19. private
  20. attr_reader :webhook_event
  21. end
  22. end
  23. end

app/services/cloudflare_turnstile_service.rb

0.0% lines covered

100.0% branches covered

42 relevant lines. 0 lines covered and 42 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "httparty"
  3. # Service for verifying Cloudflare Turnstile tokens
  4. class CloudflareTurnstileService
  5. VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
  6. class VerificationError < StandardError; end
  7. # Verifies a Turnstile token
  8. #
  9. # @param token [String] The Turnstile token from the client
  10. # @param remote_ip [String] The user's IP address
  11. # @return [Boolean] True if verification succeeds
  12. def self.verify(token, remote_ip = nil)
  13. # If Turnstile is not fully configured, allow the request through
  14. # (this handles dev environments without keys)
  15. return true unless fully_configured?
  16. if token.blank?
  17. Rails.logger.warn "Turnstile verification failed: token is blank"
  18. return false
  19. end
  20. response = HTTParty.post(
  21. VERIFY_URL,
  22. body: {
  23. secret: secret_key,
  24. response: token,
  25. remoteip: remote_ip
  26. },
  27. timeout: 5
  28. )
  29. result = JSON.parse(response.body)
  30. unless result["success"]
  31. error_codes = result["error-codes"]&.join(", ") || "unknown"
  32. Rails.logger.warn "Turnstile verification failed: #{error_codes} (#{result.inspect})"
  33. return false
  34. end
  35. true
  36. rescue JSON::ParserError, HTTParty::Error, Net::TimeoutError => e
  37. Rails.logger.error "Turnstile verification error: #{e.message}"
  38. # On network errors, fail open to avoid blocking legitimate users
  39. # in case of Cloudflare issues
  40. Rails.env.production? ? false : true
  41. end
  42. # Returns the site key for client-side use
  43. #
  44. # @return [String, nil] The Turnstile site key
  45. def self.site_key
  46. Rails.application.credentials.dig(:cloudflare, :turnstile_site_key) ||
  47. ENV["CLOUDFLARE_TURNSTILE_SITE_KEY"]
  48. end
  49. # Returns the secret key for server-side verification
  50. #
  51. # @return [String, nil] The Turnstile secret key
  52. def self.secret_key
  53. Rails.application.credentials.dig(:cloudflare, :turnstile_secret_key) ||
  54. ENV["CLOUDFLARE_TURNSTILE_SECRET_KEY"]
  55. end
  56. # Checks if Turnstile is fully configured (both keys present)
  57. #
  58. # @return [Boolean] True if both site and secret keys are present
  59. def self.fully_configured?
  60. site_key.present? && secret_key.present?
  61. end
  62. end

app/services/compute_fit_assessment_service.rb

0.0% lines covered

100.0% branches covered

135 relevant lines. 0 lines covered and 135 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "digest"
  3. # Service for computing and persisting a FitAssessment for a user and a fittable.
  4. #
  5. # The fittable is expected to be owned by the user (same `user_id`).
  6. #
  7. # Scoring approach (v1):
  8. # - Extract job skill mentions by scanning job text for `SkillTag` names (case-insensitive).
  9. # - Weight matches by the user's `UserSkill.aggregated_level`.
  10. # - Normalize to a 0..100 integer score.
  11. #
  12. # @example
  13. # ComputeFitAssessmentService.new(user: user, fittable: opportunity).call
  14. #
  15. class ComputeFitAssessmentService
  16. ALGORITHM_VERSION = "v1_keyword_skilltag_scan"
  17. # @param user [User]
  18. # @param fittable [Opportunity, SavedJob, InterviewApplication]
  19. def initialize(user:, fittable:)
  20. @user = user
  21. @fittable = fittable
  22. end
  23. # Computes and upserts a FitAssessment.
  24. #
  25. # @return [Hash] Result hash
  26. def call
  27. return error_result("User is required") unless @user
  28. return error_result("Fittable is required") unless @fittable
  29. return error_result("Fittable must belong to user") if @fittable.respond_to?(:user_id) && @fittable.user_id != @user.id
  30. job_text = build_job_text(@fittable)
  31. return upsert_failed("No job text available") if job_text.blank?
  32. matched = extract_job_skills(job_text)
  33. return upsert_failed("No skills found in job text") if matched.empty?
  34. user_skill_levels = @user.user_skills.pluck(:skill_tag_id, :aggregated_level).to_h
  35. matched_ids = matched.map { |m| m[:id] }
  36. matched_levels = matched_ids.map { |id| user_skill_levels[id].to_f }
  37. max_total = matched_ids.size * 5.0
  38. score = if max_total > 0
  39. ((matched_levels.sum / max_total) * 100).round.clamp(0, 100)
  40. end
  41. breakdown = build_breakdown(matched, user_skill_levels)
  42. inputs_digest = compute_inputs_digest(job_text, user_skill_levels)
  43. assessment = @fittable.fit_assessment
  44. if assessment&.inputs_digest == inputs_digest && assessment.computed?
  45. return { success: true, fit_assessment: assessment, skipped: true }
  46. end
  47. assessment ||= @fittable.build_fit_assessment(user: @user)
  48. assessment.assign_attributes(
  49. score: score,
  50. status: :computed,
  51. computed_at: Time.current,
  52. algorithm_version: ALGORITHM_VERSION,
  53. inputs_digest: inputs_digest,
  54. breakdown: breakdown
  55. )
  56. assessment.save!
  57. { success: true, fit_assessment: assessment }
  58. rescue ActiveRecord::RecordInvalid => e
  59. error_result(e.message)
  60. rescue StandardError => e
  61. Rails.logger.error("ComputeFitAssessmentService failed: #{e.message}")
  62. Rails.logger.error(e.backtrace.first(10).join("\n"))
  63. error_result(e.message)
  64. end
  65. private
  66. def upsert_failed(message)
  67. assessment = @fittable.fit_assessment || @fittable.build_fit_assessment(user: @user)
  68. assessment.assign_attributes(
  69. score: nil,
  70. status: :failed,
  71. computed_at: Time.current,
  72. algorithm_version: ALGORITHM_VERSION,
  73. inputs_digest: compute_inputs_digest("", {}),
  74. breakdown: { error: message }
  75. )
  76. assessment.save!
  77. error_result(message, fit_assessment: assessment)
  78. end
  79. def error_result(message, fit_assessment: nil)
  80. { success: false, error: message, fit_assessment: fit_assessment }
  81. end
  82. def build_job_text(fittable)
  83. case fittable
  84. when InterviewApplication
  85. jl = fittable.job_listing
  86. parts = []
  87. parts << fittable.display_job_role&.title
  88. parts << fittable.display_company&.name
  89. if jl
  90. parts << jl.title
  91. parts << jl.description
  92. parts << jl.requirements
  93. parts << jl.responsibilities
  94. parts << jl.custom_sections&.values&.join("\n")
  95. end
  96. parts.compact.join("\n")
  97. when Opportunity
  98. [
  99. fittable.job_role_title,
  100. fittable.company_name,
  101. fittable.key_details,
  102. fittable.email_snippet,
  103. fittable.job_url
  104. ].compact.join("\n")
  105. when SavedJob
  106. if fittable.opportunity
  107. build_job_text(fittable.opportunity)
  108. else
  109. [
  110. fittable.title,
  111. fittable.job_role_title,
  112. fittable.company_name,
  113. fittable.notes,
  114. fittable.url
  115. ].compact.join("\n")
  116. end
  117. else
  118. nil
  119. end
  120. end
  121. def extract_job_skills(job_text)
  122. text = job_text.to_s.downcase
  123. tags = SkillTag.pluck(:id, :name)
  124. tags.filter_map do |(id, name)|
  125. next if name.blank?
  126. next unless text.include?(name.downcase)
  127. { id: id, name: name }
  128. end
  129. end
  130. def build_breakdown(matched, user_skill_levels)
  131. matched_ids = matched.map { |m| m[:id] }
  132. matched_names = matched.map { |m| m[:name] }
  133. missing_ids = matched_ids.reject { |id| user_skill_levels.key?(id) }
  134. missing_names = matched.select { |m| missing_ids.include?(m[:id]) }.map { |m| m[:name] }
  135. {
  136. method: "skilltag_scan",
  137. matched_skills: matched_names.uniq.sort,
  138. missing_skills: missing_names.uniq.sort,
  139. counts: {
  140. matched_in_job: matched_names.uniq.size,
  141. matched_in_user: (matched_ids & user_skill_levels.keys).uniq.size,
  142. missing_in_user: missing_ids.uniq.size
  143. }
  144. }
  145. end
  146. def compute_inputs_digest(job_text, user_skill_levels)
  147. skills_part = user_skill_levels
  148. .sort_by { |k, _| k }
  149. .map { |k, v| "#{k}:#{v.round(2)}" }
  150. .join("|")
  151. Digest::SHA256.hexdigest([ ALGORITHM_VERSION, job_text.to_s, skills_part ].join("\n"))
  152. end
  153. end

app/services/create_job_listing_from_url_service.rb

0.0% lines covered

100.0% branches covered

23 relevant lines. 0 lines covered and 23 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for creating or finding a job listing from a URL
  3. #
  4. # @example
  5. # service = CreateJobListingFromUrlService.new(application, "https://example.com/jobs/123")
  6. # job_listing = service.call
  7. #
  8. class CreateJobListingFromUrlService
  9. # Initialize the service with an application and URL
  10. #
  11. # @param [InterviewApplication] application The interview application
  12. # @param [String] url The job listing URL
  13. def initialize(application, url)
  14. @application = application
  15. @url = url
  16. end
  17. # Creates or finds a job listing and associates it with the application
  18. #
  19. # @return [JobListing, nil] The job listing or nil if creation failed
  20. def call
  21. return nil if @url.blank?
  22. res = JobListings::UpsertFromUrlService.new(
  23. url: @url,
  24. company: @application.company,
  25. job_role: @application.job_role,
  26. title: @application.job_role.title
  27. ).call
  28. job_listing = res[:job_listing]
  29. # Associate with the application
  30. @application.update(job_listing: job_listing)
  31. # Trigger scraping if we haven't successfully scraped yet
  32. ScrapeJobListingJob.perform_later(job_listing) unless job_listing.scraped?
  33. job_listing
  34. rescue => e
  35. Rails.logger.error "Failed to create job listing from URL: #{e.message}"
  36. nil
  37. end
  38. private
  39. end

app/services/dedup/find_category_duplicates_service.rb

0.0% lines covered

100.0% branches covered

23 relevant lines. 0 lines covered and 23 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Dedup
  3. # Service for finding possible duplicate categories.
  4. #
  5. # Heuristics (deterministic):
  6. # - Same kind + same normalized name (case-insensitive, ignoring non-alphanumeric chars)
  7. class FindCategoryDuplicatesService
  8. # @param category [Category]
  9. # @param limit [Integer]
  10. def initialize(category:, limit: 10)
  11. @category = category
  12. @limit = limit
  13. end
  14. # @return [Array<Hash>] [{ record: Category, reasons: Array<String> }]
  15. def run
  16. return [] if @category.nil?
  17. key = normalize_alnum(@category.name)
  18. return [] if key.blank?
  19. Category
  20. .where.not(id: @category.id)
  21. .where(kind: @category.kind)
  22. .where("LOWER(REGEXP_REPLACE(name, '[^a-z0-9]', '', 'g')) = ?", key)
  23. .limit(@limit)
  24. .map { |c| { record: c, reasons: [ "Same normalized name within kind" ] } }
  25. end
  26. private
  27. def normalize_alnum(text)
  28. text.to_s.downcase.gsub(/[^a-z0-9]/, "")
  29. end
  30. end
  31. end

app/services/dedup/find_company_duplicates_service.rb

0.0% lines covered

100.0% branches covered

55 relevant lines. 0 lines covered and 55 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Dedup
  3. # Service for finding possible duplicate companies.
  4. #
  5. # Heuristics (deterministic):
  6. # - Same normalized name (case-insensitive, ignoring non-alphanumeric chars)
  7. # - Same website host (when present)
  8. class FindCompanyDuplicatesService
  9. require "uri"
  10. # @param company [Company]
  11. # @param limit [Integer]
  12. def initialize(company:, limit: 10)
  13. @company = company
  14. @limit = limit
  15. end
  16. # @return [Array<Hash>] [{ record: Company, reasons: Array<String> }]
  17. def run
  18. return [] if @company.nil?
  19. reasons_by_id = Hash.new { |h, k| h[k] = [] }
  20. add_name_matches!(reasons_by_id)
  21. add_website_host_matches!(reasons_by_id)
  22. reasons_by_id.map do |id, reasons|
  23. { record: Company.find(id), reasons: reasons.uniq }
  24. end.sort_by { |h| -h[:reasons].size }.first(@limit)
  25. end
  26. private
  27. def add_name_matches!(reasons_by_id)
  28. key = normalize_alnum(@company.name)
  29. return if key.blank?
  30. Company
  31. .where.not(id: @company.id)
  32. .where("LOWER(REGEXP_REPLACE(name, '[^a-z0-9]', '', 'g')) = ?", key)
  33. .limit(@limit)
  34. .pluck(:id)
  35. .each { |id| reasons_by_id[id] << "Same normalized name" }
  36. end
  37. def add_website_host_matches!(reasons_by_id)
  38. host = extract_host(@company.website)
  39. return if host.blank?
  40. Company
  41. .where.not(id: @company.id)
  42. .where("website ILIKE ?", "%#{host}%")
  43. .limit(@limit * 3)
  44. .find_each do |candidate|
  45. next unless extract_host(candidate.website) == host
  46. reasons_by_id[candidate.id] << "Same website host (#{host})"
  47. end
  48. end
  49. def normalize_alnum(text)
  50. text.to_s.downcase.gsub(/[^a-z0-9]/, "")
  51. end
  52. def extract_host(url)
  53. return nil if url.blank?
  54. begin
  55. uri = URI.parse(url.strip)
  56. host = uri.host
  57. host = URI.parse("https://#{url.strip}").host if host.blank?
  58. host&.downcase
  59. rescue URI::InvalidURIError
  60. nil
  61. end
  62. end
  63. end
  64. end

app/services/dedup/find_job_role_duplicates_service.rb

0.0% lines covered

100.0% branches covered

43 relevant lines. 0 lines covered and 43 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Dedup
  3. # Service for finding possible duplicate job roles.
  4. #
  5. # Heuristics (deterministic):
  6. # - Same normalized title (case-insensitive, ignoring non-alphanumeric chars)
  7. # - Same normalized title + same category_id (stronger signal)
  8. class FindJobRoleDuplicatesService
  9. # @param job_role [JobRole]
  10. # @param limit [Integer]
  11. def initialize(job_role:, limit: 10)
  12. @job_role = job_role
  13. @limit = limit
  14. end
  15. # @return [Array<Hash>] [{ record: JobRole, reasons: Array<String> }]
  16. def run
  17. return [] if @job_role.nil?
  18. reasons_by_id = Hash.new { |h, k| h[k] = [] }
  19. add_title_matches!(reasons_by_id)
  20. add_title_and_category_matches!(reasons_by_id)
  21. reasons_by_id.map do |id, reasons|
  22. { record: JobRole.find(id), reasons: reasons.uniq }
  23. end.sort_by { |h| -h[:reasons].size }.first(@limit)
  24. end
  25. private
  26. def add_title_matches!(reasons_by_id)
  27. key = normalize_alnum(@job_role.title)
  28. return if key.blank?
  29. JobRole
  30. .where.not(id: @job_role.id)
  31. .where("LOWER(REGEXP_REPLACE(title, '[^a-z0-9]', '', 'g')) = ?", key)
  32. .limit(@limit)
  33. .pluck(:id)
  34. .each { |id| reasons_by_id[id] << "Same normalized title" }
  35. end
  36. def add_title_and_category_matches!(reasons_by_id)
  37. return if @job_role.category_id.blank?
  38. key = normalize_alnum(@job_role.title)
  39. return if key.blank?
  40. JobRole
  41. .where.not(id: @job_role.id)
  42. .where(category_id: @job_role.category_id)
  43. .where("LOWER(REGEXP_REPLACE(title, '[^a-z0-9]', '', 'g')) = ?", key)
  44. .limit(@limit)
  45. .pluck(:id)
  46. .each { |id| reasons_by_id[id] << "Same title within same category" }
  47. end
  48. def normalize_alnum(text)
  49. text.to_s.downcase.gsub(/[^a-z0-9]/, "")
  50. end
  51. end
  52. end

app/services/dedup/find_skill_tag_duplicates_service.rb

0.0% lines covered

100.0% branches covered

43 relevant lines. 0 lines covered and 43 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Dedup
  3. # Service for finding possible duplicate skill tags.
  4. #
  5. # Heuristics (deterministic):
  6. # - Same normalized name (case-insensitive, ignoring non-alphanumeric chars)
  7. # - Same normalized name + same category_id (stronger signal)
  8. class FindSkillTagDuplicatesService
  9. # @param skill_tag [SkillTag]
  10. # @param limit [Integer]
  11. def initialize(skill_tag:, limit: 10)
  12. @skill_tag = skill_tag
  13. @limit = limit
  14. end
  15. # @return [Array<Hash>] [{ record: SkillTag, reasons: Array<String> }]
  16. def run
  17. return [] if @skill_tag.nil?
  18. reasons_by_id = Hash.new { |h, k| h[k] = [] }
  19. add_name_matches!(reasons_by_id)
  20. add_name_and_category_matches!(reasons_by_id)
  21. reasons_by_id.map do |id, reasons|
  22. { record: SkillTag.find(id), reasons: reasons.uniq }
  23. end.sort_by { |h| -h[:reasons].size }.first(@limit)
  24. end
  25. private
  26. def add_name_matches!(reasons_by_id)
  27. key = normalize_alnum(@skill_tag.name)
  28. return if key.blank?
  29. SkillTag
  30. .where.not(id: @skill_tag.id)
  31. .where("LOWER(REGEXP_REPLACE(name, '[^a-z0-9]', '', 'g')) = ?", key)
  32. .limit(@limit)
  33. .pluck(:id)
  34. .each { |id| reasons_by_id[id] << "Same normalized name" }
  35. end
  36. def add_name_and_category_matches!(reasons_by_id)
  37. return if @skill_tag.category_id.blank?
  38. key = normalize_alnum(@skill_tag.name)
  39. return if key.blank?
  40. SkillTag
  41. .where.not(id: @skill_tag.id)
  42. .where(category_id: @skill_tag.category_id)
  43. .where("LOWER(REGEXP_REPLACE(name, '[^a-z0-9]', '', 'g')) = ?", key)
  44. .limit(@limit)
  45. .pluck(:id)
  46. .each { |id| reasons_by_id[id] << "Same name within same category" }
  47. end
  48. def normalize_alnum(text)
  49. text.to_s.downcase.gsub(/[^a-z0-9]/, "")
  50. end
  51. end
  52. end

app/services/dedup/merge_category_service.rb

0.0% lines covered

100.0% branches covered

38 relevant lines. 0 lines covered and 38 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Dedup
  3. # Service for merging duplicate categories (within the same kind).
  4. #
  5. # Repoints all referencing records from a source category to a target category,
  6. # then disables the source category (default).
  7. #
  8. # @example
  9. # Dedup::MergeCategoryService.new(source_category: a, target_category: b).run
  10. #
  11. class MergeCategoryService
  12. # @param source_category [Category]
  13. # @param target_category [Category]
  14. # @param disable_source [Boolean] If true, disable source after merge (default)
  15. def initialize(source_category:, target_category:, disable_source: true)
  16. @source_category = source_category
  17. @target_category = target_category
  18. @disable_source = disable_source
  19. end
  20. # Runs the merge.
  21. #
  22. # @return [Category] The target category
  23. def run
  24. validate!
  25. Category.transaction do
  26. case @source_category.kind.to_s
  27. when "job_role"
  28. JobRole.where(category_id: @source_category.id).update_all(category_id: @target_category.id)
  29. when "skill_tag"
  30. SkillTag.where(category_id: @source_category.id).update_all(category_id: @target_category.id)
  31. else
  32. raise ArgumentError, "Unsupported category kind: #{@source_category.kind}"
  33. end
  34. finalize_source!
  35. end
  36. @target_category
  37. end
  38. private
  39. def validate!
  40. raise ArgumentError, "source_category is required" if @source_category.nil?
  41. raise ArgumentError, "target_category is required" if @target_category.nil?
  42. raise ArgumentError, "source_category and target_category must differ" if @source_category.id == @target_category.id
  43. raise ArgumentError, "categories must have the same kind" if @source_category.kind != @target_category.kind
  44. end
  45. def finalize_source!
  46. if @disable_source
  47. @source_category.disable! unless @source_category.disabled?
  48. else
  49. @source_category.destroy!
  50. end
  51. end
  52. end
  53. end

app/services/dedup/merge_company_service.rb

0.0% lines covered

100.0% branches covered

66 relevant lines. 0 lines covered and 66 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Dedup
  3. # Service for merging duplicate companies.
  4. #
  5. # Moves all associations from a source company to a target company, then disables
  6. # the source company (default) to preserve history while preventing future use.
  7. #
  8. # @example
  9. # service = Dedup::MergeCompanyService.new(source_company: a, target_company: b)
  10. # service.run
  11. #
  12. class MergeCompanyService
  13. # @param source_company [Company]
  14. # @param target_company [Company]
  15. # @param disable_source [Boolean] If true, disable source after merge (default)
  16. def initialize(source_company:, target_company:, disable_source: true)
  17. @source_company = source_company
  18. @target_company = target_company
  19. @disable_source = disable_source
  20. end
  21. # Runs the merge.
  22. #
  23. # @return [Company] The target company
  24. # @raise [ArgumentError] If source and target are invalid
  25. # @raise [ActiveRecord::RecordInvalid] If updates fail
  26. def run
  27. validate!
  28. Company.transaction do
  29. move_job_listings!
  30. move_interview_applications!
  31. move_user_targets!
  32. move_resume_targets!
  33. move_users_current_company!
  34. move_email_senders!
  35. finalize_source!
  36. end
  37. @target_company
  38. end
  39. private
  40. def validate!
  41. raise ArgumentError, "source_company is required" if @source_company.nil?
  42. raise ArgumentError, "target_company is required" if @target_company.nil?
  43. raise ArgumentError, "source_company and target_company must differ" if @source_company.id == @target_company.id
  44. end
  45. def move_job_listings!
  46. JobListing.where(company_id: @source_company.id).update_all(company_id: @target_company.id)
  47. end
  48. def move_interview_applications!
  49. InterviewApplication.where(company_id: @source_company.id).update_all(company_id: @target_company.id)
  50. end
  51. def move_users_current_company!
  52. User.where(current_company_id: @source_company.id).update_all(current_company_id: @target_company.id)
  53. end
  54. def move_email_senders!
  55. EmailSender.where(company_id: @source_company.id).update_all(company_id: @target_company.id)
  56. EmailSender.where(auto_detected_company_id: @source_company.id).update_all(auto_detected_company_id: @target_company.id)
  57. end
  58. def move_user_targets!
  59. UserTargetCompany.where(company_id: @source_company.id).find_each do |utc|
  60. if UserTargetCompany.exists?(user_id: utc.user_id, company_id: @target_company.id)
  61. utc.destroy!
  62. else
  63. utc.update!(company_id: @target_company.id)
  64. end
  65. end
  66. end
  67. def move_resume_targets!
  68. UserResumeTargetCompany.where(company_id: @source_company.id).find_each do |urtc|
  69. if UserResumeTargetCompany.exists?(user_resume_id: urtc.user_resume_id, company_id: @target_company.id)
  70. urtc.destroy!
  71. else
  72. urtc.update!(company_id: @target_company.id)
  73. end
  74. end
  75. end
  76. def finalize_source!
  77. if @disable_source
  78. @source_company.disable! unless @source_company.disabled?
  79. else
  80. @source_company.destroy!
  81. end
  82. end
  83. end
  84. end

app/services/dedup/merge_job_role_service.rb

0.0% lines covered

100.0% branches covered

61 relevant lines. 0 lines covered and 61 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Dedup
  3. # Service for merging duplicate job roles.
  4. #
  5. # Moves all associations from a source job role to a target job role, then disables
  6. # the source job role (default) to preserve history while preventing future use.
  7. #
  8. # @example
  9. # Dedup::MergeJobRoleService.new(source_job_role: a, target_job_role: b).run
  10. #
  11. class MergeJobRoleService
  12. # @param source_job_role [JobRole]
  13. # @param target_job_role [JobRole]
  14. # @param disable_source [Boolean] If true, disable source after merge (default)
  15. def initialize(source_job_role:, target_job_role:, disable_source: true)
  16. @source_job_role = source_job_role
  17. @target_job_role = target_job_role
  18. @disable_source = disable_source
  19. end
  20. # Runs the merge.
  21. #
  22. # @return [JobRole] The target job role
  23. def run
  24. validate!
  25. JobRole.transaction do
  26. move_job_listings!
  27. move_interview_applications!
  28. move_user_targets!
  29. move_resume_targets!
  30. move_users_current_job_role!
  31. finalize_source!
  32. end
  33. @target_job_role
  34. end
  35. private
  36. def validate!
  37. raise ArgumentError, "source_job_role is required" if @source_job_role.nil?
  38. raise ArgumentError, "target_job_role is required" if @target_job_role.nil?
  39. raise ArgumentError, "source_job_role and target_job_role must differ" if @source_job_role.id == @target_job_role.id
  40. end
  41. def move_job_listings!
  42. JobListing.where(job_role_id: @source_job_role.id).update_all(job_role_id: @target_job_role.id)
  43. end
  44. def move_interview_applications!
  45. InterviewApplication.where(job_role_id: @source_job_role.id).update_all(job_role_id: @target_job_role.id)
  46. end
  47. def move_users_current_job_role!
  48. User.where(current_job_role_id: @source_job_role.id).update_all(current_job_role_id: @target_job_role.id)
  49. end
  50. def move_user_targets!
  51. UserTargetJobRole.where(job_role_id: @source_job_role.id).find_each do |utr|
  52. if UserTargetJobRole.exists?(user_id: utr.user_id, job_role_id: @target_job_role.id)
  53. utr.destroy!
  54. else
  55. utr.update!(job_role_id: @target_job_role.id)
  56. end
  57. end
  58. end
  59. def move_resume_targets!
  60. UserResumeTargetJobRole.where(job_role_id: @source_job_role.id).find_each do |urtr|
  61. if UserResumeTargetJobRole.exists?(user_resume_id: urtr.user_resume_id, job_role_id: @target_job_role.id)
  62. urtr.destroy!
  63. else
  64. urtr.update!(job_role_id: @target_job_role.id)
  65. end
  66. end
  67. end
  68. def finalize_source!
  69. if @disable_source
  70. @source_job_role.disable! unless @source_job_role.disabled?
  71. else
  72. @source_job_role.destroy!
  73. end
  74. end
  75. end
  76. end

app/services/dedup/merge_skill_tag_service.rb

0.0% lines covered

100.0% branches covered

59 relevant lines. 0 lines covered and 59 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Dedup
  3. # Service for merging duplicate skill tags.
  4. #
  5. # Moves join associations from a source skill tag to a target skill tag, handling
  6. # uniqueness constraints, then disables the source tag (default).
  7. #
  8. # @example
  9. # Dedup::MergeSkillTagService.new(source_skill_tag: a, target_skill_tag: b).run
  10. #
  11. class MergeSkillTagService
  12. # @param source_skill_tag [SkillTag]
  13. # @param target_skill_tag [SkillTag]
  14. # @param disable_source [Boolean] If true, disable source after merge (default)
  15. def initialize(source_skill_tag:, target_skill_tag:, disable_source: true)
  16. @source_skill_tag = source_skill_tag
  17. @target_skill_tag = target_skill_tag
  18. @disable_source = disable_source
  19. end
  20. # Runs the merge.
  21. #
  22. # @return [SkillTag] The target skill tag
  23. def run
  24. validate!
  25. SkillTag.transaction do
  26. move_application_skill_tags!
  27. move_resume_skills!
  28. move_user_skills!
  29. finalize_source!
  30. end
  31. @target_skill_tag
  32. end
  33. private
  34. def validate!
  35. raise ArgumentError, "source_skill_tag is required" if @source_skill_tag.nil?
  36. raise ArgumentError, "target_skill_tag is required" if @target_skill_tag.nil?
  37. raise ArgumentError, "source_skill_tag and target_skill_tag must differ" if @source_skill_tag.id == @target_skill_tag.id
  38. end
  39. def move_application_skill_tags!
  40. ApplicationSkillTag.where(skill_tag_id: @source_skill_tag.id).find_each do |join|
  41. if ApplicationSkillTag.exists?(interview_id: join.interview_id, skill_tag_id: @target_skill_tag.id)
  42. join.destroy!
  43. else
  44. join.update!(skill_tag_id: @target_skill_tag.id)
  45. end
  46. end
  47. end
  48. def move_resume_skills!
  49. ResumeSkill.where(skill_tag_id: @source_skill_tag.id).find_each do |rs|
  50. if ResumeSkill.exists?(user_resume_id: rs.user_resume_id, skill_tag_id: @target_skill_tag.id)
  51. rs.destroy!
  52. else
  53. rs.update!(skill_tag_id: @target_skill_tag.id)
  54. end
  55. end
  56. end
  57. def move_user_skills!
  58. UserSkill.where(skill_tag_id: @source_skill_tag.id).find_each do |us|
  59. if UserSkill.exists?(user_id: us.user_id, skill_tag_id: @target_skill_tag.id)
  60. us.destroy!
  61. else
  62. us.update!(skill_tag_id: @target_skill_tag.id)
  63. end
  64. end
  65. end
  66. def finalize_source!
  67. if @disable_source
  68. @source_skill_tag.disable! unless @source_skill_tag.disabled?
  69. else
  70. @source_skill_tag.destroy!
  71. end
  72. end
  73. end
  74. end

app/services/feedback_analysis_service.rb

0.0% lines covered

100.0% branches covered

43 relevant lines. 0 lines covered and 43 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for analyzing interview feedback and generating AI summaries
  3. class FeedbackAnalysisService
  4. # @param interview_feedback [InterviewFeedback] The interview feedback to analyze
  5. def initialize(interview_feedback)
  6. @interview_feedback = interview_feedback
  7. end
  8. # Analyzes the feedback and generates summary and tags
  9. # @return [Hash] Hash containing ai_summary and tags
  10. def analyze
  11. # TODO: Implement actual AI analysis using OpenAI/Anthropic API
  12. # For now, return placeholder data
  13. {
  14. ai_summary: generate_placeholder_summary,
  15. tags: extract_placeholder_tags,
  16. recommended_action: generate_placeholder_recommendation
  17. }
  18. end
  19. # Generates AI summary for the feedback
  20. # @return [String] Generated summary
  21. def generate_summary
  22. # TODO: Implement actual AI summary generation
  23. generate_placeholder_summary
  24. end
  25. # Extracts skill tags from feedback
  26. # @return [Array<String>] Array of extracted tags
  27. def extract_tags
  28. # TODO: Implement actual tag extraction
  29. extract_placeholder_tags
  30. end
  31. # Generates a recommended action
  32. # @return [String] Recommended action
  33. def generate_recommendation
  34. # TODO: Implement actual recommendation generation
  35. generate_placeholder_recommendation
  36. end
  37. private
  38. def generate_placeholder_summary
  39. strengths = @interview_feedback.went_well.present? ? "strong performance in discussed areas" : "areas to celebrate"
  40. improvements = @interview_feedback.to_improve.present? ? "opportunities for growth identified" : "room for development"
  41. "You showed #{strengths}. There are #{improvements}. Continue building on your strengths while addressing areas for improvement."
  42. end
  43. def extract_placeholder_tags
  44. # Simple keyword extraction as placeholder
  45. text = [
  46. @interview_feedback.went_well,
  47. @interview_feedback.to_improve,
  48. @interview_feedback.self_reflection
  49. ].compact.join(" ")
  50. common_skills = [
  51. "Communication", "System Design", "Problem Solving",
  52. "Leadership", "Technical Skills", "Collaboration"
  53. ]
  54. common_skills.select { |skill| text.downcase.include?(skill.downcase) }
  55. end
  56. def generate_placeholder_recommendation
  57. return nil if @interview_feedback.to_improve.blank?
  58. "Focus on practicing the areas you identified for improvement. Consider mock interviews or study sessions targeting these specific topics."
  59. end
  60. end

app/services/gmail/client_service.rb

0.0% lines covered

100.0% branches covered

45 relevant lines. 0 lines covered and 45 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for managing Gmail API client connections
  3. #
  4. # @example
  5. # service = Gmail::ClientService.new(connected_account)
  6. # client = service.client
  7. #
  8. class Gmail::ClientService
  9. # @return [ConnectedAccount] The connected account
  10. attr_reader :connected_account
  11. # Initialize the client service
  12. #
  13. # @param connected_account [ConnectedAccount] The connected account with OAuth tokens
  14. def initialize(connected_account)
  15. @connected_account = connected_account
  16. end
  17. # Returns a configured Gmail API client
  18. #
  19. # @return [Google::Apis::GmailV1::GmailService]
  20. # @raise [Gmail::TokenExpiredError] If the token is expired and can't be refreshed
  21. def client
  22. refresh_token_if_needed!
  23. @client ||= begin
  24. service = Google::Apis::GmailV1::GmailService.new
  25. service.authorization = authorization
  26. service
  27. end
  28. end
  29. # Returns the user's email address (me)
  30. #
  31. # @return [String]
  32. def user_id
  33. "me"
  34. end
  35. private
  36. # Creates an authorization object for the API
  37. #
  38. # @return [Signet::OAuth2::Client]
  39. def authorization
  40. Signet::OAuth2::Client.new(
  41. client_id: Rails.application.credentials.dig(:google, :client_id),
  42. client_secret: Rails.application.credentials.dig(:google, :client_secret),
  43. token_credential_uri: "https://oauth2.googleapis.com/token",
  44. access_token: connected_account.access_token,
  45. refresh_token: connected_account.refresh_token,
  46. expires_at: connected_account.expires_at
  47. )
  48. end
  49. # Refreshes the token if it's expired or expiring soon
  50. #
  51. # @return [void]
  52. # @raise [Gmail::TokenExpiredError] If the token can't be refreshed
  53. def refresh_token_if_needed!
  54. return unless connected_account.token_expired? || connected_account.token_expiring_soon?
  55. return unless connected_account.refreshable?
  56. refresh_token!
  57. end
  58. # Refreshes the access token using the refresh token
  59. #
  60. # @return [void]
  61. # @raise [Gmail::TokenExpiredError] If the refresh fails
  62. def refresh_token!
  63. auth = authorization
  64. auth.refresh!
  65. connected_account.update!(
  66. access_token: auth.access_token,
  67. expires_at: Time.at(auth.expires_at)
  68. )
  69. # Reset the client to use new tokens
  70. @client = nil
  71. rescue Signet::AuthorizationError => e
  72. Rails.logger.error "Gmail token refresh failed: #{e.message}"
  73. raise Gmail::Errors::TokenExpiredError, "Failed to refresh Gmail token. Please reconnect your account."
  74. end
  75. end

app/services/gmail/company_matcher_service.rb

0.0% lines covered

100.0% branches covered

107 relevant lines. 0 lines covered and 107 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for matching email senders to companies
  3. # Analyzes email domains and content to auto-detect company associations
  4. #
  5. # @example
  6. # matcher = Gmail::CompanyMatcherService.new
  7. # company = matcher.find_company_for_email("recruiter@company.com")
  8. #
  9. class Gmail::CompanyMatcherService
  10. # Known ATS system domains that should be associated with the sending company, not the ATS
  11. ATS_DOMAINS = Gmail::SyncService::RECRUITER_DOMAINS.freeze
  12. # Common email provider domains to ignore
  13. GENERIC_DOMAINS = %w[
  14. gmail.com yahoo.com hotmail.com outlook.com live.com
  15. icloud.com me.com mac.com aol.com mail.com
  16. protonmail.com pm.me tutanota.com zoho.com
  17. yandex.com gmx.com fastmail.com
  18. ].freeze
  19. # Initialize the service
  20. def initialize
  21. @domain_cache = {}
  22. end
  23. # Finds or auto-detects a company for an email address
  24. #
  25. # @param email [String] The email address
  26. # @param sender_name [String, nil] The sender's display name
  27. # @return [Company, nil]
  28. def find_company_for_email(email, sender_name = nil)
  29. return nil if email.blank?
  30. domain = extract_domain(email)
  31. return nil if generic_domain?(domain)
  32. # Check cache first
  33. return @domain_cache[domain] if @domain_cache.key?(domain)
  34. # Try to find company
  35. company = find_by_domain(domain) ||
  36. find_by_website(domain) ||
  37. find_by_name_from_domain(domain) ||
  38. find_by_sender_name(sender_name)
  39. @domain_cache[domain] = company
  40. company
  41. end
  42. # Processes an email sender and updates company associations
  43. #
  44. # @param email_sender [EmailSender] The sender to process
  45. # @return [Hash] Result with detected company info
  46. def process_sender(email_sender)
  47. return { success: false, error: "No sender provided" } unless email_sender
  48. company = find_company_for_email(email_sender.email, email_sender.name)
  49. if company
  50. email_sender.update!(auto_detected_company: company) unless email_sender.company_id.present?
  51. { success: true, company: company, auto_detected: true }
  52. else
  53. { success: true, company: nil, auto_detected: false }
  54. end
  55. rescue StandardError => e
  56. Rails.logger.warn "CompanyMatcher failed for #{email_sender.email}: #{e.message}"
  57. { success: false, error: e.message }
  58. end
  59. # Bulk processes all unassigned senders
  60. #
  61. # @param limit [Integer] Maximum senders to process
  62. # @return [Hash] Processing statistics
  63. def process_unassigned_senders(limit: 100)
  64. senders = EmailSender.unassigned.where(auto_detected_company_id: nil).limit(limit)
  65. stats = { processed: 0, matched: 0, unmatched: 0, errors: 0 }
  66. senders.find_each do |sender|
  67. result = process_sender(sender)
  68. stats[:processed] += 1
  69. if result[:success]
  70. result[:company] ? stats[:matched] += 1 : stats[:unmatched] += 1
  71. else
  72. stats[:errors] += 1
  73. end
  74. end
  75. stats
  76. end
  77. # Finds all senders for a specific domain
  78. #
  79. # @param domain [String] The email domain
  80. # @return [Array<EmailSender>]
  81. def senders_for_domain(domain)
  82. EmailSender.by_domain(domain).order(:email)
  83. end
  84. # Assigns a company to all senders from a domain
  85. #
  86. # @param domain [String] The email domain
  87. # @param company [Company] The company to assign
  88. # @param verify [Boolean] Whether to mark as verified
  89. # @return [Integer] Number of senders updated
  90. def assign_domain_to_company(domain, company, verify: true)
  91. EmailSender.by_domain(domain).update_all(
  92. company_id: company.id,
  93. verified: verify,
  94. updated_at: Time.current
  95. )
  96. end
  97. private
  98. # Extracts domain from email address
  99. #
  100. # @param email [String]
  101. # @return [String]
  102. def extract_domain(email)
  103. email.to_s.split("@").last&.downcase&.strip || ""
  104. end
  105. # Checks if domain is a generic email provider
  106. #
  107. # @param domain [String]
  108. # @return [Boolean]
  109. def generic_domain?(domain)
  110. GENERIC_DOMAINS.include?(domain.downcase)
  111. end
  112. # Checks if domain is an ATS system
  113. #
  114. # @param domain [String]
  115. # @return [Boolean]
  116. def ats_domain?(domain)
  117. ATS_DOMAINS.any? { |ats| domain.include?(ats) }
  118. end
  119. # Finds company by existing email sender domain association
  120. #
  121. # @param domain [String]
  122. # @return [Company, nil]
  123. def find_by_domain(domain)
  124. # Check if we've already associated this domain with a company
  125. existing_sender = EmailSender.by_domain(domain)
  126. .where.not(company_id: nil)
  127. .first
  128. existing_sender&.company
  129. end
  130. # Finds company by website URL containing domain
  131. #
  132. # @param domain [String]
  133. # @return [Company, nil]
  134. def find_by_website(domain)
  135. return nil if ats_domain?(domain)
  136. Company.where("website ILIKE ?", "%#{domain}%").first ||
  137. Company.where("website ILIKE ?", "%#{domain.split('.').first}%").first
  138. end
  139. # Finds company by matching domain name to company name
  140. #
  141. # @param domain [String]
  142. # @return [Company, nil]
  143. def find_by_name_from_domain(domain)
  144. return nil if ats_domain?(domain)
  145. # Extract the main part of the domain (e.g., "google" from "google.com")
  146. domain_name = domain.split(".").first
  147. return nil if domain_name.length < 3
  148. # Try exact match first
  149. Company.where("LOWER(name) = ?", domain_name.downcase).first ||
  150. # Try partial match
  151. Company.where("LOWER(name) LIKE ?", "%#{domain_name.downcase}%")
  152. .where("LENGTH(name) < ?", domain_name.length + 10) # Avoid matching "Google Inc" to "goo"
  153. .first
  154. end
  155. # Finds company from sender's display name
  156. #
  157. # @param sender_name [String, nil]
  158. # @return [Company, nil]
  159. def find_by_sender_name(sender_name)
  160. return nil if sender_name.blank?
  161. # Extract potential company name from formats like:
  162. # "Jane at Company" or "Company Recruiting" or "Company HR"
  163. patterns = [
  164. /(?:at|from|with)\s+([A-Z][A-Za-z0-9\s&]+?)(?:\s|$)/i,
  165. /^([A-Z][A-Za-z0-9\s&]+?)\s+(?:Recruiting|HR|Talent|Team|Careers?)/i
  166. ]
  167. patterns.each do |pattern|
  168. match = sender_name.match(pattern)
  169. if match
  170. company_name = match[1].strip
  171. company = Company.where("LOWER(name) = ?", company_name.downcase).first
  172. return company if company
  173. end
  174. end
  175. nil
  176. end
  177. end

app/services/gmail/email_processor_service.rb

0.0% lines covered

100.0% branches covered

437 relevant lines. 0 lines covered and 437 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for processing synced emails to classify type and match to applications
  3. #
  4. # @example
  5. # processor = Gmail::EmailProcessorService.new(synced_email)
  6. # result = processor.run
  7. #
  8. class Gmail::EmailProcessorService
  9. PROXY_SENDER_DOMAINS = %w[
  10. linkedin.com
  11. mail.linkedin.com
  12. ].freeze
  13. PROXY_SENDER_EMAILS = %w[
  14. inmail-hit-reply@linkedin.com
  15. ].freeze
  16. # Keywords for detecting email types
  17. EMAIL_TYPE_PATTERNS = {
  18. interview_invite: [
  19. /interview\s+(invitation|invite|scheduled|confirmed)/i,
  20. /schedule\s+(a|an|your|the)\s+interview/i,
  21. /invit(e|ing)\s+you\s+(to|for)\s+(an?\s+)?interview/i,
  22. /would\s+like\s+to\s+interview/i,
  23. /meet\s+with\s+(our|the)\s+team/i,
  24. /phone\s+screen/i,
  25. /technical\s+interview/i,
  26. /on-?site\s+interview/i,
  27. /video\s+interview/i,
  28. /zoom\s+(interview|call|meeting)/i,
  29. # Subject line patterns (high signal)
  30. /\b(first|initial|final|next|second|third)\s+interview\b/i,
  31. /interview\s+(with|at)\s+\w+/i,
  32. # Recruiter scheduling interview
  33. /I\s+recruit/i,
  34. /recruiter\s+(at|for|from)/i,
  35. /set\s+up\s+(a\s+)?time\s+(for\s+us\s+)?to\s+(chat|talk|meet|speak)/i,
  36. /excited\s+to\s+(get\s+to\s+)?know\s+you/i
  37. ],
  38. scheduling: [
  39. /schedule\s+(a\s+|the\s+)?(call|meeting|time)/i,
  40. /book\s+(a\s+)?time/i,
  41. /calendly/i,
  42. /goodtime\.io/i,
  43. /pick\s+a\s+time/i,
  44. /available\s+times?/i,
  45. /when\s+are\s+you\s+available/i,
  46. /set\s+up\s+(a\s+)?time/i,
  47. /visit\s+this\s+link/i
  48. ],
  49. application_confirmation: [
  50. /thank\s+you\s+for\s+(applying|your\s+application)/i,
  51. /application\s+(received|submitted|confirmed)/i,
  52. /we\s+(have\s+)?received\s+your\s+application/i,
  53. /successfully\s+applied/i,
  54. /application\s+for\s+.+\s+position/i
  55. ],
  56. rejection: [
  57. /we\s+(regret|unfortunately|are\s+sorry)/i,
  58. /not\s+(be\s+)?moving\s+forward/i,
  59. /decided\s+(not\s+)?to\s+proceed/i,
  60. /position\s+has\s+been\s+filled/i,
  61. /not\s+a\s+(good\s+)?fit/i,
  62. /won'?t\s+be\s+(moving|proceeding)/i,
  63. /pursuing\s+other\s+candidates/i
  64. ],
  65. offer: [
  66. /offer\s+(letter|of\s+employment)/i,
  67. /pleased\s+to\s+offer/i,
  68. /extend(ing)?\s+(an?\s+)?offer/i,
  69. /job\s+offer/i,
  70. /congratulations/i,
  71. /welcome\s+to\s+the\s+team/i,
  72. /excited\s+to\s+have\s+you\s+join/i
  73. ],
  74. assessment: [
  75. /coding\s+(challenge|test|assessment)/i,
  76. /take-?home\s+(assignment|test|project)/i,
  77. /technical\s+assessment/i,
  78. /skills?\s+assessment/i,
  79. /hackerrank/i,
  80. /codility/i,
  81. /leetcode/i,
  82. /complete\s+the\s+(following\s+)?assessment/i
  83. ],
  84. follow_up: [
  85. /following\s+up/i,
  86. /checking\s+in/i,
  87. /wanted\s+to\s+follow\s+up/i,
  88. /any\s+updates?/i,
  89. /status\s+of\s+(my|your)\s+application/i
  90. ],
  91. thank_you: [
  92. /thank\s+you\s+for\s+(your\s+time|meeting|interviewing)/i,
  93. /great\s+meeting\s+you/i,
  94. /enjoyed\s+(speaking|talking|meeting)/i
  95. ],
  96. recruiter_outreach: [
  97. /exciting\s+(opportunity|role|position)/i,
  98. /perfect\s+fit/i,
  99. /great\s+fit/i,
  100. /your\s+(profile|background|experience)/i,
  101. /reaching\s+out/i,
  102. /interested\s+in\s+you/i,
  103. /open\s+position/i,
  104. /hiring\s+for/i,
  105. /would\s+you\s+be\s+interested/i,
  106. /great\s+match/i,
  107. /ideal\s+candidate/i,
  108. /thought\s+of\s+you/i,
  109. /came\s+across\s+your/i,
  110. /found\s+your\s+profile/i,
  111. /saw\s+your\s+resume/i,
  112. /impressive\s+background/i,
  113. /looking\s+for\s+someone/i,
  114. /we\s+have\s+an\s+opening/i,
  115. /new\s+opportunity/i,
  116. /career\s+opportunity/i
  117. ],
  118. round_feedback: [
  119. # Pass/move forward patterns
  120. /you('ve| have)?\s+(passed|cleared|moved forward)/i,
  121. /pleased\s+to\s+inform\s+you/i,
  122. /congratulations.*next\s+(round|stage)/i,
  123. /moving\s+(you\s+)?(forward|ahead)/i,
  124. /advancing\s+to\s+(the\s+)?next/i,
  125. /proceed(ing)?\s+to\s+(the\s+)?(next|final)/i,
  126. /happy\s+to\s+share.*(passed|moved)/i,
  127. /great\s+news.*(passed|next\s+round)/i,
  128. # Rejection patterns (for single round, not full rejection)
  129. /unfortunately.*not\s+(moving|proceeding)/i,
  130. /decided\s+not\s+to\s+move\s+forward/i,
  131. # Feedback patterns
  132. /feedback\s+(from|on)\s+your\s+(interview|round)/i,
  133. /interview\s+feedback/i,
  134. /results?\s+(of|from)\s+(your\s+)?interview/i,
  135. /outcome\s+(of|from)\s+(your\s+)?interview/i,
  136. /update\s+on\s+your\s+(interview|round)/i,
  137. # Waitlist patterns
  138. /waitlist(ed)?/i,
  139. /hold\s+for\s+now/i,
  140. /keep\s+you\s+in\s+mind/i
  141. ]
  142. }.freeze
  143. # Priority order when multiple email types match
  144. EMAIL_TYPE_PRIORITY = %w[
  145. rejection
  146. round_feedback
  147. offer
  148. assessment
  149. scheduling
  150. interview_invite
  151. application_confirmation
  152. follow_up
  153. thank_you
  154. recruiter_outreach
  155. ].freeze
  156. SELF_SENT_TYPE_BLACKLIST = %w[
  157. rejection
  158. round_feedback
  159. offer
  160. ].freeze
  161. # @return [SyncedEmail] The email to process
  162. attr_reader :synced_email
  163. # Initialize the processor
  164. #
  165. # @param synced_email [SyncedEmail] The email to process
  166. # @param pipeline_run [Signals::EmailPipelineRun, nil] Optional pipeline run for observability
  167. def initialize(synced_email, pipeline_run: nil)
  168. @synced_email = synced_email
  169. @pipeline_recorder = Signals::Observability::EmailPipelineRecorder.for_run(pipeline_run)
  170. end
  171. # Runs the processing pipeline
  172. #
  173. # @return [Hash] Processing result
  174. def run
  175. return already_processed_result if synced_email.processed? || synced_email.ignored?
  176. ActiveRecord::Base.transaction do
  177. if pipeline_recorder
  178. pipeline_recorder.measure(:email_classification) do
  179. classify_email_type
  180. { "email_type" => synced_email.email_type }
  181. end
  182. pipeline_recorder.measure(:company_detection) do
  183. detect_company
  184. { "detected_company" => synced_email.detected_company }
  185. end
  186. pipeline_recorder.measure(:application_match) do
  187. match_to_application
  188. { "interview_application_id" => synced_email.interview_application_id }
  189. end
  190. else
  191. classify_email_type
  192. detect_company
  193. match_to_application
  194. end
  195. synced_email.save!
  196. end
  197. {
  198. success: true,
  199. email_type: synced_email.email_type,
  200. matched_application: synced_email.interview_application_id,
  201. detected_company: synced_email.detected_company
  202. }
  203. rescue StandardError => e
  204. Rails.logger.error "Email processing failed for #{synced_email.id}: #{e.message}"
  205. synced_email.mark_failed!(e.message)
  206. { success: false, error: e.message }
  207. end
  208. private
  209. # Returns result for already processed emails
  210. #
  211. # @return [Hash]
  212. def already_processed_result
  213. {
  214. success: true,
  215. email_type: synced_email.email_type,
  216. matched_application: synced_email.interview_application_id,
  217. already_processed: true
  218. }
  219. end
  220. def pipeline_recorder
  221. @pipeline_recorder
  222. end
  223. # Classifies the email type based on content patterns
  224. # Emails from target companies get boosted relevance
  225. #
  226. # @return [void]
  227. def classify_email_type
  228. # LinkedIn and similar proxy senders can include misleading keywords (e.g. "JOB OFFER")
  229. # in the subject even when the content is just recruiter outreach.
  230. if proxy_sender?
  231. detector = Gmail::OpportunityDetectorService.new(synced_email)
  232. if detector.recruiter_outreach?
  233. synced_email.email_type = "recruiter_outreach"
  234. return
  235. end
  236. end
  237. content = classification_content
  238. matched_types = EMAIL_TYPE_PATTERNS.filter_map do |type, patterns|
  239. type.to_s if patterns.any? { |pattern| content.match?(pattern) }
  240. end
  241. if self_sent_email?
  242. matched_types -= SELF_SENT_TYPE_BLACKLIST
  243. end
  244. # "job offer" alone is not strong enough signal for proxy senders (LinkedIn InMail, etc.)
  245. if proxy_sender? && matched_types.include?("offer") && !strong_offer_signal?(content)
  246. matched_types -= [ "offer" ]
  247. end
  248. if matched_types.any?
  249. synced_email.email_type = choose_email_type(matched_types)
  250. return
  251. end
  252. # Boost relevance: If from a target company but no pattern matched,
  253. # classify as recruiter_outreach since it's likely relevant
  254. if from_target_company?
  255. synced_email.email_type = "recruiter_outreach"
  256. return
  257. end
  258. # Default to "other" if no pattern matched and not from target company
  259. synced_email.email_type = "other"
  260. end
  261. # Chooses the email type based on priority order
  262. #
  263. # @param matched_types [Array<String>]
  264. # @return [String]
  265. def choose_email_type(matched_types)
  266. EMAIL_TYPE_PRIORITY.find { |type| matched_types.include?(type) } || matched_types.first
  267. end
  268. # Builds the content used for email classification
  269. #
  270. # @return [String]
  271. def classification_content
  272. subject = synced_email.subject.to_s
  273. body = primary_body_content
  274. return [ subject, body ].join(" ").strip if body.present?
  275. [ subject, synced_email.snippet, synced_email.body_preview ].compact.join(" ")
  276. end
  277. # Extracts the primary body content (removes quoted replies/forwards)
  278. #
  279. # @return [String]
  280. def primary_body_content
  281. body = synced_email.body_preview.presence || synced_email.snippet.to_s
  282. return "" if body.blank?
  283. lines = body.split("\n")
  284. cutoff = lines.index { |line| reply_separator?(line) }
  285. trimmed_lines = cutoff ? lines[0...cutoff] : lines
  286. trimmed_lines = trimmed_lines.reject { |line| line.lstrip.start_with?(">") }
  287. trimmed = trimmed_lines.join("\n")
  288. trimmed = trimmed.strip
  289. trimmed.presence || body
  290. end
  291. # Checks if a line indicates the start of quoted content
  292. #
  293. # @param line [String]
  294. # @return [Boolean]
  295. def reply_separator?(line)
  296. normalized = line.to_s.strip
  297. [
  298. /^On .+ wrote:$/i,
  299. /^On .+sent:$/i,
  300. /^On .+wrote$/i,
  301. /^From:\s+/i,
  302. /^Sent:\s+/i,
  303. /^To:\s+/i,
  304. /^Subject:\s+/i,
  305. /^-----Original Message-----/i,
  306. /^----- Forwarded message -----/i,
  307. /^Begin forwarded message:/i
  308. ].any? { |pattern| normalized.match?(pattern) }
  309. end
  310. # Checks if the email was sent by the user
  311. #
  312. # @return [Boolean]
  313. def self_sent_email?
  314. sender = synced_email.from_email.to_s.downcase
  315. return false if sender.blank?
  316. account_email = synced_email.connected_account&.email.to_s.downcase
  317. user_email = synced_email.user&.email_address.to_s.downcase
  318. sender == account_email || sender == user_email
  319. end
  320. # Checks if the email is from a company the user is targeting
  321. #
  322. # @return [Boolean]
  323. def from_target_company?
  324. return false if synced_email.from_email.blank?
  325. sender_domain = synced_email.from_email.split("@").last&.downcase
  326. return false if sender_domain.blank? || generic_domain?(sender_domain)
  327. target_domains = user_target_company_domains
  328. target_domains.any? do |company_domain|
  329. sender_domain == company_domain ||
  330. sender_domain.end_with?(".#{company_domain}") ||
  331. company_domain.end_with?(".#{sender_domain}")
  332. end
  333. end
  334. # Returns email domains for the user's target companies
  335. #
  336. # @return [Array<String>]
  337. def user_target_company_domains
  338. @user_target_company_domains ||= synced_email.user.target_companies.filter_map do |company|
  339. next unless company.website.present?
  340. url = company.website.strip
  341. url = "https://#{url}" unless url.start_with?("http")
  342. uri = URI.parse(url)
  343. uri.host&.gsub(/^www\./, "")&.downcase
  344. rescue URI::InvalidURIError
  345. nil
  346. end.uniq
  347. end
  348. # Detects company name from email content
  349. #
  350. # @return [void]
  351. def detect_company
  352. # Try sender's company first
  353. if synced_email.email_sender&.effective_company
  354. synced_email.detected_company = synced_email.email_sender.effective_company.name
  355. return
  356. end
  357. # Try to extract from email domain
  358. domain = synced_email.from_email.split("@").last
  359. company = find_company_by_domain(domain)
  360. if company
  361. synced_email.detected_company = company.name
  362. # Also update the sender's auto-detected company
  363. synced_email.email_sender&.update(auto_detected_company: company)
  364. return
  365. end
  366. # Try to extract company name from subject or content
  367. extracted_name = extract_company_from_content
  368. synced_email.detected_company = extracted_name if extracted_name
  369. end
  370. # Finds a company by email domain
  371. #
  372. # @param domain [String] The email domain
  373. # @return [Company, nil]
  374. def find_company_by_domain(domain)
  375. return nil if domain.blank? || generic_domain?(domain)
  376. # Try exact website match
  377. Company.where("website ILIKE ?", "%#{domain}%").first ||
  378. # Try company name match
  379. Company.where("LOWER(name) = ?", extract_company_from_domain(domain).downcase).first
  380. end
  381. # Checks if domain is a generic email provider
  382. #
  383. # @param domain [String]
  384. # @return [Boolean]
  385. def generic_domain?(domain)
  386. generic_domains = %w[
  387. gmail.com yahoo.com hotmail.com outlook.com
  388. icloud.com aol.com mail.com protonmail.com
  389. ]
  390. generic_domains.include?(domain.downcase)
  391. end
  392. # Extracts company name from domain
  393. #
  394. # @param domain [String]
  395. # @return [String]
  396. def extract_company_from_domain(domain)
  397. # Remove common TLDs and get the main part
  398. domain.split(".").first.titleize
  399. end
  400. # Extracts company name from email content
  401. #
  402. # @return [String, nil]
  403. def extract_company_from_content
  404. content = "#{synced_email.subject} #{synced_email.snippet}"
  405. # Pattern: "at [Company]" or "from [Company]" or "[Company] Team"
  406. patterns = [
  407. /(?:at|from|with)\s+([A-Z][A-Za-z0-9\s&]+?)(?:\s+team|\s+inc|\s+llc|\s+corp|,|\.|!|\?|$)/i,
  408. /([A-Z][A-Za-z0-9\s&]+?)\s+(?:team|recruiting|talent|hr)\s+/i,
  409. /application\s+(?:for|to|at)\s+([A-Z][A-Za-z0-9\s&]+)/i
  410. ]
  411. patterns.each do |pattern|
  412. match = content.match(pattern)
  413. return match[1].strip if match && match[1].length > 2 && match[1].length < 50
  414. end
  415. nil
  416. end
  417. # Matches the email to an existing application
  418. #
  419. # @return [void]
  420. def match_to_application
  421. return if synced_email.interview_application_id.present?
  422. application = find_matching_application
  423. if application
  424. synced_email.interview_application = application
  425. synced_email.status = :processed
  426. else
  427. # Leave as pending for manual review
  428. synced_email.status = :pending
  429. end
  430. end
  431. # Finds an application that matches this email
  432. #
  433. # Uses multiple strategies in order of reliability:
  434. # 1. Thread-based matching (same conversation = same application)
  435. # 2. Sender consistency (emails from same person go to same application)
  436. # 3. Company name matching
  437. # 4. Sender's assigned company
  438. # 5. Domain-based matching
  439. #
  440. # @return [InterviewApplication, nil]
  441. def find_matching_application
  442. user = synced_email.user
  443. # Proxy senders (e.g. LinkedIn InMail) can only auto-match with high confidence.
  444. # We treat "same thread already matched" as high-confidence, but we never use
  445. # sender-consistency or other heuristics for proxy senders.
  446. if proxy_sender?
  447. return match_proxy_sender_by_thread(user)
  448. end
  449. # Strategy 1: Match by email thread (same thread = same application)
  450. # This is highest priority to maintain conversation continuity
  451. if synced_email.thread_id.present?
  452. existing = SyncedEmail.where(user: user, thread_id: synced_email.thread_id)
  453. .where.not(interview_application_id: nil)
  454. .first
  455. return existing.interview_application if existing
  456. end
  457. # Strategy 2: Match by sender consistency (same sender = same application)
  458. # If we already have emails from this sender matched to an active application,
  459. # keep them together to avoid splitting conversations across applications
  460. if synced_email.from_email.present?
  461. existing = SyncedEmail.where(user: user, from_email: synced_email.from_email)
  462. .where.not(interview_application_id: nil)
  463. .joins(:interview_application)
  464. .where(interview_applications: { status: :active })
  465. .order(email_date: :desc)
  466. .first
  467. return existing.interview_application if existing
  468. end
  469. # Strategy 3: Match by company name
  470. if synced_email.detected_company.present?
  471. company = Company.where("LOWER(name) = ?", synced_email.detected_company.downcase).first
  472. if company
  473. app = user.interview_applications
  474. .where(company: company)
  475. .where(status: :active)
  476. .order(created_at: :desc)
  477. .first
  478. return app if app
  479. end
  480. end
  481. # Strategy 4: Match by sender's company
  482. if synced_email.email_sender&.effective_company
  483. app = user.interview_applications
  484. .where(company: synced_email.email_sender.effective_company)
  485. .where(status: :active)
  486. .order(created_at: :desc)
  487. .first
  488. return app if app
  489. end
  490. # Strategy 5: Match by sender domain to application companies
  491. app = match_by_sender_domain(user)
  492. return app if app
  493. nil
  494. end
  495. # Matches email to application by comparing sender domain to company websites
  496. #
  497. # @param user [User] The user
  498. # @return [InterviewApplication, nil]
  499. def match_by_sender_domain(user)
  500. sender_domain = synced_email.from_email.split("@").last&.downcase
  501. return nil if sender_domain.blank? || generic_domain?(sender_domain)
  502. # Find applications where the company website matches the sender domain
  503. user.interview_applications
  504. .includes(:company)
  505. .where(status: :active)
  506. .find do |app|
  507. company = app.company
  508. next unless company&.website.present?
  509. company_domain = extract_domain_from_website(company.website)
  510. next unless company_domain
  511. # Check if domains match
  512. sender_domain == company_domain ||
  513. sender_domain.end_with?(".#{company_domain}") ||
  514. company_domain.end_with?(".#{sender_domain}")
  515. end
  516. end
  517. # Extracts domain from a website URL
  518. #
  519. # @param website [String] The website URL
  520. # @return [String, nil]
  521. def extract_domain_from_website(website)
  522. url = website.strip
  523. url = "https://#{url}" unless url.start_with?("http")
  524. uri = URI.parse(url)
  525. uri.host&.gsub(/^www\./, "")&.downcase
  526. rescue URI::InvalidURIError
  527. nil
  528. end
  529. def proxy_sender?
  530. from = synced_email.from_email.to_s.downcase
  531. return true if PROXY_SENDER_EMAILS.include?(from)
  532. domain = from.split("@").last
  533. return false if domain.blank?
  534. PROXY_SENDER_DOMAINS.include?(domain)
  535. end
  536. def match_proxy_sender_by_thread(user)
  537. return nil if synced_email.thread_id.blank?
  538. app_ids =
  539. SyncedEmail.where(user: user, thread_id: synced_email.thread_id)
  540. .where.not(interview_application_id: nil)
  541. .distinct
  542. .pluck(:interview_application_id)
  543. return nil unless app_ids.size == 1
  544. user.interview_applications.find_by(id: app_ids.first, status: :active) ||
  545. user.interview_applications.find_by(id: app_ids.first)
  546. end
  547. def strong_offer_signal?(content)
  548. return false if content.blank?
  549. [
  550. /offer\s+(letter|of\s+employment)/i,
  551. /pleased\s+to\s+offer/i,
  552. /extend(ing)?\s+(an?\s+)?offer/i,
  553. /welcome\s+to\s+the\s+team/i,
  554. /excited\s+to\s+have\s+you\s+join/i
  555. ].any? { |pattern| content.match?(pattern) }
  556. end
  557. end

app/services/gmail/errors.rb

0.0% lines covered

100.0% branches covered

9 relevant lines. 0 lines covered and 9 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Gmail service exceptions
  3. module Gmail
  4. module Errors
  5. class TokenExpiredError < StandardError; end
  6. class AuthorizationError < Google::Apis::AuthorizationError; end
  7. class RateLimitError < Google::Apis::RateLimitError; end
  8. class ServerError < Google::Apis::ServerError; end
  9. class ClientError < Google::Apis::ClientError; end
  10. end
  11. end

app/services/gmail/opportunity_detector_service.rb

0.0% lines covered

100.0% branches covered

196 relevant lines. 0 lines covered and 196 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Gmail
  3. # Service for detecting recruiter outreach emails
  4. # Distinguishes between application-related emails and unsolicited recruiter contact
  5. #
  6. # @example
  7. # detector = Gmail::OpportunityDetectorService.new(synced_email)
  8. # if detector.recruiter_outreach?
  9. # opportunity = detector.create_opportunity!
  10. # end
  11. #
  12. class OpportunityDetectorService
  13. # Keywords that indicate recruiter outreach (not application-related)
  14. OUTREACH_KEYWORDS = [
  15. # Direct outreach phrases
  16. "opportunity",
  17. "exciting role",
  18. "perfect fit",
  19. "great fit",
  20. "your profile",
  21. "your background",
  22. "your experience",
  23. "reaching out",
  24. "interested in you",
  25. "open position",
  26. "hiring for",
  27. "would you be interested",
  28. "great match",
  29. "ideal candidate",
  30. "thought of you",
  31. "came across your",
  32. "found your profile",
  33. "saw your resume",
  34. "impressive background",
  35. "looking for someone",
  36. "we have an opening",
  37. "new opportunity",
  38. "career opportunity"
  39. ].freeze
  40. # Keywords that indicate this is a reply to an application (NOT outreach)
  41. APPLICATION_KEYWORDS = [
  42. "thank you for applying",
  43. "your application",
  44. "application received",
  45. "application status",
  46. "interview scheduled",
  47. "interview confirmed",
  48. "next steps in the process",
  49. "move forward with your",
  50. "following up on your application"
  51. ].freeze
  52. # Domains that typically send recruiter outreach
  53. RECRUITER_DOMAINS = [
  54. "linkedin.com",
  55. "mail.linkedin.com",
  56. "hired.com",
  57. "angel.co",
  58. "wellfound.com",
  59. "dice.com",
  60. "indeed.com",
  61. "ziprecruiter.com",
  62. "glassdoor.com",
  63. "monster.com"
  64. ].freeze
  65. # Title patterns for recruiters
  66. RECRUITER_TITLE_PATTERNS = [
  67. /recruiter/i,
  68. /talent\s*(acquisition|partner|scout)/i,
  69. /sourcer/i,
  70. /headhunter/i,
  71. /staffing/i,
  72. /hr\s*manager/i,
  73. /hiring\s*manager/i,
  74. /people\s*ops/i
  75. ].freeze
  76. # Patterns indicating forwarded messages
  77. FORWARDED_PATTERNS = [
  78. /fwd?:/i,
  79. /forwarded message/i,
  80. /---------- Forwarded message/i,
  81. /Begin forwarded message/i
  82. ].freeze
  83. # LinkedIn-specific patterns
  84. LINKEDIN_PATTERNS = [
  85. /linkedin\.com/i,
  86. /sent you a message/i,
  87. /wants to connect/i,
  88. /InMail/i,
  89. /via LinkedIn/i
  90. ].freeze
  91. # @return [SyncedEmail] The email to analyze
  92. attr_reader :synced_email
  93. # Initialize the detector
  94. #
  95. # @param synced_email [SyncedEmail] The email to analyze
  96. def initialize(synced_email)
  97. @synced_email = synced_email
  98. end
  99. # Checks if this email is recruiter outreach
  100. #
  101. # @return [Boolean] True if this appears to be recruiter outreach
  102. def recruiter_outreach?
  103. return false if application_related?
  104. outreach_score >= 0.5
  105. end
  106. # Returns a confidence score for recruiter outreach detection
  107. #
  108. # @return [Float] Score between 0 and 1
  109. def outreach_score
  110. score = 0.0
  111. total_weight = 0.0
  112. # Check outreach keywords (weight: 0.4)
  113. if has_outreach_keywords?
  114. score += 0.4
  115. end
  116. total_weight += 0.4
  117. # Check recruiter sender patterns (weight: 0.3)
  118. if from_recruiter?
  119. score += 0.3
  120. end
  121. total_weight += 0.3
  122. # Check for LinkedIn/job board forwarding (weight: 0.2)
  123. if forwarded_from_job_platform?
  124. score += 0.2
  125. end
  126. total_weight += 0.2
  127. # Check if first contact (no thread history) (weight: 0.1)
  128. if first_contact?
  129. score += 0.1
  130. end
  131. total_weight += 0.1
  132. score / total_weight
  133. end
  134. # Detects the source type of the opportunity
  135. #
  136. # @return [String] One of: direct_email, linkedin_forward, referral, other
  137. def detect_source_type
  138. if linkedin_forward?
  139. "linkedin_forward"
  140. elsif has_referral_indicators?
  141. "referral"
  142. elsif from_recruiter?
  143. "direct_email"
  144. else
  145. "other"
  146. end
  147. end
  148. # Checks if this is a forwarded email
  149. #
  150. # @return [Boolean]
  151. def forwarded?
  152. content = combined_content
  153. FORWARDED_PATTERNS.any? { |pattern| content.match?(pattern) }
  154. end
  155. # Checks if this is forwarded from LinkedIn
  156. #
  157. # @return [Boolean]
  158. def linkedin_forward?
  159. content = combined_content
  160. from_domain = extract_domain(synced_email.from_email)
  161. # Check if from LinkedIn or mentions LinkedIn
  162. from_domain == "linkedin.com" ||
  163. from_domain == "mail.linkedin.com" ||
  164. LINKEDIN_PATTERNS.any? { |pattern| content.match?(pattern) }
  165. end
  166. # Creates an Opportunity from this email
  167. #
  168. # @return [Opportunity] The created opportunity
  169. def create_opportunity!
  170. Opportunity.create!(
  171. user: synced_email.user,
  172. synced_email: synced_email,
  173. status: "new",
  174. source_type: detect_source_type,
  175. recruiter_name: synced_email.from_name,
  176. recruiter_email: synced_email.from_email,
  177. email_snippet: synced_email.snippet || synced_email.body_preview&.truncate(500),
  178. ai_confidence_score: outreach_score,
  179. extracted_data: {
  180. is_forwarded: forwarded?,
  181. original_source: detect_original_source
  182. }
  183. )
  184. end
  185. private
  186. # Returns combined content for analysis
  187. #
  188. # @return [String]
  189. def combined_content
  190. [
  191. synced_email.subject,
  192. synced_email.snippet,
  193. synced_email.body_preview
  194. ].compact.join(" ").downcase
  195. end
  196. # Checks if content has outreach keywords
  197. #
  198. # @return [Boolean]
  199. def has_outreach_keywords?
  200. content = combined_content
  201. OUTREACH_KEYWORDS.any? { |keyword| content.include?(keyword.downcase) }
  202. end
  203. # Checks if this appears to be application-related (not outreach)
  204. #
  205. # @return [Boolean]
  206. def application_related?
  207. content = combined_content
  208. APPLICATION_KEYWORDS.any? { |keyword| content.include?(keyword.downcase) }
  209. end
  210. # Checks if sender appears to be a recruiter
  211. #
  212. # @return [Boolean]
  213. def from_recruiter?
  214. # Check sender name for recruiter title patterns
  215. if synced_email.from_name.present?
  216. return true if RECRUITER_TITLE_PATTERNS.any? { |pattern| synced_email.from_name.match?(pattern) }
  217. end
  218. # Check if from recruiter domain
  219. domain = extract_domain(synced_email.from_email)
  220. RECRUITER_DOMAINS.include?(domain)
  221. end
  222. # Checks if forwarded from a job platform
  223. #
  224. # @return [Boolean]
  225. def forwarded_from_job_platform?
  226. linkedin_forward? || (forwarded? && from_recruiter?)
  227. end
  228. # Checks if this is the first contact in a thread
  229. #
  230. # @return [Boolean]
  231. def first_contact?
  232. return true if synced_email.thread_id.blank?
  233. synced_email.thread_count == 1
  234. end
  235. # Checks for referral indicators
  236. #
  237. # @return [Boolean]
  238. def has_referral_indicators?
  239. content = combined_content
  240. referral_patterns = [
  241. /referred by/i,
  242. /recommended you/i,
  243. /suggested I reach out/i,
  244. /your colleague/i,
  245. /mutual connection/i
  246. ]
  247. referral_patterns.any? { |pattern| content.match?(pattern) }
  248. end
  249. # Detects the original source of the opportunity
  250. #
  251. # @return [String, nil]
  252. def detect_original_source
  253. if linkedin_forward?
  254. "linkedin"
  255. elsif forwarded?
  256. "forwarded"
  257. else
  258. nil
  259. end
  260. end
  261. # Extracts domain from email address
  262. #
  263. # @param email [String] Email address
  264. # @return [String] Domain portion
  265. def extract_domain(email)
  266. return "" if email.blank?
  267. email.split("@").last&.downcase || ""
  268. end
  269. end
  270. end

app/services/gmail/sync_service.rb

0.0% lines covered

100.0% branches covered

514 relevant lines. 0 lines covered and 514 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for syncing emails from Gmail that may contain interview-related content
  3. #
  4. # @example
  5. # service = Gmail::SyncService.new(connected_account)
  6. # result = service.run
  7. #
  8. class Gmail::SyncService < ApplicationService
  9. # Keywords that indicate an email might be interview-related
  10. INTERVIEW_KEYWORDS = [
  11. "interview",
  12. "interviewing",
  13. "phone screen",
  14. "technical interview",
  15. "coding challenge",
  16. "assessment",
  17. "hiring",
  18. "application status",
  19. "job application",
  20. "thank you for applying",
  21. "next steps",
  22. "schedule a call",
  23. "meet the team",
  24. "offer letter",
  25. "job offer",
  26. "congratulations",
  27. "we regret",
  28. "unfortunately",
  29. "position has been filled"
  30. ].freeze
  31. # Keywords that indicate recruiter outreach (new opportunity)
  32. RECRUITER_OUTREACH_KEYWORDS = [
  33. "opportunity",
  34. "exciting role",
  35. "perfect fit",
  36. "your profile",
  37. "your background",
  38. "reaching out",
  39. "interested in you",
  40. "open position",
  41. "hiring for",
  42. "would you be interested",
  43. "great match",
  44. "ideal candidate",
  45. "came across your"
  46. ].freeze
  47. # Common recruiting email domains
  48. RECRUITER_DOMAINS = [
  49. "greenhouse.io",
  50. "lever.co",
  51. "workday.com",
  52. "icims.com",
  53. "taleo.net",
  54. "jobvite.com",
  55. "smartrecruiters.com",
  56. "ashbyhq.com",
  57. "bamboohr.com"
  58. ].freeze
  59. # Domains that send recruiter outreach (LinkedIn, job boards)
  60. OUTREACH_DOMAINS = [
  61. "linkedin.com",
  62. "mail.linkedin.com",
  63. "hired.com",
  64. "angel.co",
  65. "wellfound.com",
  66. "dice.com",
  67. "indeed.com",
  68. "ziprecruiter.com"
  69. ].freeze
  70. # Patterns that indicate an email is NOT job-related (marketing, newsletters, etc.)
  71. IRRELEVANT_PATTERNS = [
  72. /unsubscribe.*preferences/i,
  73. /weekly\s+digest/i,
  74. /daily\s+digest/i,
  75. /newsletter/i,
  76. /marketing\s+email/i,
  77. /promotional/i,
  78. /you\s+might\s+like/i,
  79. /trending\s+(jobs?|posts?|articles?)/i,
  80. /people\s+you\s+may\s+know/i,
  81. /people\s+viewed\s+your\s+profile/i,
  82. /who\s+viewed\s+your\s+profile/i,
  83. /your\s+network\s+updates?/i,
  84. /connection\s+request/i,
  85. /wants\s+to\s+connect/i,
  86. /endorsed\s+you/i,
  87. /congratulate/i,
  88. /work\s+anniversary/i,
  89. /birthday/i,
  90. /new\s+job\s+alert/i,
  91. /jobs?\s+you\s+may\s+be\s+interested/i,
  92. /similar\s+jobs?/i,
  93. /job\s+recommendations?/i,
  94. /security\s+alert/i,
  95. /password\s+reset/i,
  96. /verify\s+your\s+email/i,
  97. /confirm\s+your\s+email/i,
  98. /account\s+update/i,
  99. /privacy\s+policy/i,
  100. /terms\s+of\s+service/i
  101. ].freeze
  102. # Subjects that indicate generic platform notifications (not direct recruiter contact)
  103. NOTIFICATION_SUBJECTS = [
  104. /^you\s+have\s+\d+\s+new/i,
  105. /^your\s+daily\s+job/i,
  106. /^your\s+weekly/i,
  107. /^new\s+jobs?\s+for\s+you/i,
  108. /^jobs?\s+matching\s+your/i,
  109. /^people\s+are\s+looking/i,
  110. /^who'?s\s+viewed/i,
  111. /^you\s+appeared\s+in/i,
  112. /^\d+\s+new\s+(jobs?|messages?|notifications?)/i
  113. ].freeze
  114. # @return [ConnectedAccount] The connected account
  115. attr_reader :connected_account
  116. # @return [User] The user
  117. attr_reader :user
  118. # @return [Integer] Maximum number of emails to process per sync
  119. attr_reader :max_results
  120. # Initialize the sync service
  121. #
  122. # @param connected_account [ConnectedAccount] The connected account with OAuth tokens
  123. # @param max_results [Integer] Maximum number of emails to fetch (default: 100)
  124. def initialize(connected_account, max_results: 100)
  125. @connected_account = connected_account
  126. @user = connected_account.user
  127. @max_results = max_results
  128. @synced_emails = []
  129. end
  130. # Runs the sync process
  131. #
  132. # @return [Hash] Results of the sync
  133. def run
  134. return { success: false, error: "Account not connected" } unless connected_account&.google?
  135. return { success: false, error: "Sync disabled" } unless connected_account.sync_enabled?
  136. begin
  137. emails = fetch_interview_emails
  138. parsed_emails = parse_emails(emails)
  139. # Store and process emails
  140. sync_results = store_and_process_emails(parsed_emails)
  141. connected_account.mark_synced!
  142. {
  143. success: true,
  144. emails_found: emails.size,
  145. emails_parsed: parsed_emails.size,
  146. emails_new: sync_results[:new_count],
  147. emails_processed: sync_results[:processed_count],
  148. emails_matched: sync_results[:matched_count],
  149. opportunities_created: sync_results[:opportunities_count],
  150. synced_at: Time.current
  151. }
  152. rescue Gmail::Errors::TokenExpiredError => e
  153. { success: false, error: e.message, needs_reauth: true }
  154. rescue Google::Apis::Error => e
  155. Rails.logger.error "Gmail API error: #{e.message}"
  156. notify_error(
  157. e,
  158. context: "gmail_sync",
  159. severity: "error",
  160. user: user,
  161. operation: "gmail_api",
  162. connected_account_id: connected_account.id
  163. )
  164. { success: false, error: "Gmail API error: #{e.message}" }
  165. rescue StandardError => e
  166. Rails.logger.error "Gmail sync error: #{e.class} - #{e.message}"
  167. Rails.logger.error e.backtrace.first(10).join("\n")
  168. notify_error(
  169. e,
  170. context: "gmail_sync",
  171. severity: "error",
  172. user: user,
  173. operation: "sync_run",
  174. connected_account_id: connected_account.id
  175. )
  176. { success: false, error: "Sync failed: #{e.message}" }
  177. end
  178. end
  179. # Returns counts of synced emails by status
  180. #
  181. # @return [Hash]
  182. def sync_stats
  183. {
  184. total: user.synced_emails.from_account(connected_account).count,
  185. pending: user.synced_emails.from_account(connected_account).pending.count,
  186. processed: user.synced_emails.from_account(connected_account).processed.count,
  187. matched: user.synced_emails.from_account(connected_account).matched.count,
  188. opportunities: user.opportunities.actionable.count
  189. }
  190. end
  191. private
  192. # Returns the Gmail client
  193. #
  194. # @return [Gmail::ClientService]
  195. def client_service
  196. @client_service ||= Gmail::ClientService.new(connected_account)
  197. end
  198. # Returns the Gmail API client
  199. #
  200. # @return [Google::Apis::GmailV1::GmailService]
  201. def gmail
  202. client_service.client
  203. end
  204. # Fetches emails that may be interview-related
  205. #
  206. # @return [Array<Google::Apis::GmailV1::Message>]
  207. def fetch_interview_emails
  208. # Build a search query for interview-related emails
  209. query = build_search_query
  210. # Get messages matching the query
  211. response = gmail.list_user_messages(
  212. client_service.user_id,
  213. q: query,
  214. max_results: max_results
  215. )
  216. return [] unless response.messages
  217. # Fetch full message details for each message
  218. response.messages.map do |message_ref|
  219. gmail.get_user_message(client_service.user_id, message_ref.id)
  220. end
  221. end
  222. # Builds the Gmail search query
  223. # Refined to reduce irrelevant emails while still catching relevant ones
  224. #
  225. # @return [String]
  226. def build_search_query
  227. # Search for emails from the last 30 days
  228. after_date = 30.days.ago.strftime("%Y/%m/%d")
  229. # Build keyword query for interview-related emails (high signal)
  230. interview_keyword_query = INTERVIEW_KEYWORDS.map { |kw| "\"#{kw}\"" }.join(" OR ")
  231. # Build keyword query for recruiter outreach
  232. outreach_keyword_query = RECRUITER_OUTREACH_KEYWORDS.map { |kw| "\"#{kw}\"" }.join(" OR ")
  233. # Build domain query for ATS systems (these are always relevant)
  234. ats_domain_query = RECRUITER_DOMAINS.map { |d| "from:#{d}" }.join(" OR ")
  235. # Exclusions to filter out noise
  236. exclusions = [
  237. "-subject:\"unsubscribe\"",
  238. "-subject:\"newsletter\"",
  239. "-subject:\"digest\"",
  240. "-subject:\"weekly jobs\"",
  241. "-subject:\"daily jobs\"",
  242. "-subject:\"job alert\"",
  243. "-subject:\"jobs for you\"",
  244. "-subject:\"people you may know\"",
  245. "-subject:\"who viewed your profile\"",
  246. "-subject:\"connection request\"",
  247. "-from:noreply",
  248. "-from:no-reply",
  249. "-from:notifications@",
  250. "-from:marketing@"
  251. ].join(" ")
  252. # Combine all queries - look for keyword matches OR from ATS systems
  253. # Note: We removed OUTREACH_DOMAINS (LinkedIn, Indeed, etc.) from the OR clause
  254. # because they generate too much noise. Instead, we rely on keywords to catch
  255. # relevant recruiter outreach from those platforms.
  256. all_keywords = "(#{interview_keyword_query} OR #{outreach_keyword_query})"
  257. ats_only = "(#{ats_domain_query})"
  258. # Also fetch emails from companies user has applied to or is targeting
  259. # These are always relevant regardless of keywords
  260. user_company_query = user_company_email_query
  261. # Also fetch emails from known recruiters for this user
  262. known_recruiter_query = known_recruiter_sender_query
  263. query_parts = [ all_keywords, ats_only, user_company_query, known_recruiter_query ].compact
  264. "after:#{after_date} in:inbox -in:spam -in:trash #{exclusions} (#{query_parts.join(' OR ')})"
  265. end
  266. # Returns email domains for companies the user has applied to or is targeting
  267. # These companies are always relevant for email sync
  268. #
  269. # @return [Array<String>] List of email domains
  270. def user_company_email_domains
  271. @user_company_email_domains ||= begin
  272. # Get companies from applications and targets
  273. applied_companies = user.interview_applications
  274. .includes(:company)
  275. .where(status: :active)
  276. .map(&:company)
  277. .compact
  278. target_companies = user.target_companies.to_a
  279. # Combine and dedupe
  280. all_companies = (applied_companies + target_companies).uniq
  281. # Extract domains from company websites
  282. all_companies.filter_map do |company|
  283. extract_domain_from_company(company)
  284. end.uniq
  285. end
  286. end
  287. # Builds a Gmail query fragment for user company domains
  288. #
  289. # @return [String, nil] Query fragment or nil if no domains
  290. def user_company_email_query
  291. user_domains = user_company_email_domains
  292. return nil if user_domains.empty?
  293. "(#{user_domains.map { |d| "from:#{d}" }.join(' OR ')})"
  294. end
  295. # Builds a Gmail query fragment for known recruiter senders
  296. # Includes known recruiter emails and names from prior synced emails
  297. #
  298. # @return [String, nil] Query fragment or nil if no known senders
  299. def known_recruiter_sender_query
  300. sender_types = %w[recruiter hr hiring_manager]
  301. senders = EmailSender.joins(:synced_emails)
  302. .where(synced_emails: { user_id: user.id })
  303. .where(sender_type: sender_types)
  304. .order(last_seen_at: :desc)
  305. .limit(25)
  306. sender_emails = senders.map(&:email).filter_map do |email|
  307. next if email.blank?
  308. "from:#{email}"
  309. end
  310. sender_ids = senders.map(&:id)
  311. sender_names = if sender_ids.any?
  312. user.synced_emails
  313. .where(email_sender_id: sender_ids)
  314. .where.not(from_name: [ nil, "" ])
  315. .distinct
  316. .limit(10)
  317. .pluck(:from_name)
  318. else
  319. []
  320. end
  321. name_terms = sender_names.filter_map do |name|
  322. clean_name = name.to_s.delete('"').strip
  323. next if clean_name.blank?
  324. %(from:"#{clean_name}")
  325. end
  326. terms = (sender_emails + name_terms).uniq
  327. return nil if terms.empty?
  328. "(#{terms.join(' OR ')})"
  329. end
  330. # Extracts email domain from a company's website
  331. #
  332. # @param company [Company] The company
  333. # @return [String, nil] The domain (e.g., "google.com")
  334. def extract_domain_from_company(company)
  335. return nil unless company.website.present?
  336. # Parse website URL to get domain
  337. url = company.website.strip
  338. url = "https://#{url}" unless url.start_with?("http")
  339. uri = URI.parse(url)
  340. domain = uri.host&.gsub(/^www\./, "")
  341. # Skip generic domains
  342. return nil if domain.blank? || generic_email_domain?(domain)
  343. domain
  344. rescue URI::InvalidURIError
  345. nil
  346. end
  347. # Checks if a domain is a generic email provider (not company-specific)
  348. #
  349. # @param domain [String] The domain to check
  350. # @return [Boolean]
  351. def generic_email_domain?(domain)
  352. generic = %w[
  353. gmail.com yahoo.com hotmail.com outlook.com
  354. icloud.com aol.com mail.com protonmail.com
  355. live.com msn.com ymail.com
  356. ]
  357. generic.include?(domain.downcase)
  358. end
  359. # Parses email messages into structured data
  360. #
  361. # @param messages [Array<Google::Apis::GmailV1::Message>]
  362. # @return [Array<Hash>]
  363. def parse_emails(messages)
  364. messages.filter_map { |message| parse_email(message) }
  365. end
  366. # Parses a single email message
  367. #
  368. # @param message [Google::Apis::GmailV1::Message]
  369. # @return [Hash, nil]
  370. def parse_email(message)
  371. headers = extract_headers(message)
  372. body_content = extract_body_content(message)
  373. {
  374. id: message.id,
  375. thread_id: message.thread_id,
  376. subject: headers["Subject"],
  377. from: headers["From"],
  378. to: headers["To"],
  379. date: parse_date(headers["Date"]),
  380. snippet: message.snippet,
  381. labels: message.label_ids,
  382. body_preview: body_content[:plain],
  383. body_html: body_content[:html]
  384. }
  385. rescue StandardError => e
  386. Rails.logger.error "Failed to parse email #{message.id}: #{e.class} - #{e.message}"
  387. notify_error(
  388. e,
  389. context: "gmail_sync",
  390. severity: "warning",
  391. user: user,
  392. gmail_message_id: message.id
  393. )
  394. nil
  395. end
  396. # Extracts headers from a message
  397. #
  398. # @param message [Google::Apis::GmailV1::Message]
  399. # @return [Hash]
  400. def extract_headers(message)
  401. return {} unless message.payload&.headers
  402. message.payload.headers.each_with_object({}) do |header, hash|
  403. hash[header.name] = header.value
  404. end
  405. end
  406. # Parses a date string from email headers
  407. #
  408. # @param date_string [String]
  409. # @return [DateTime, nil]
  410. def parse_date(date_string)
  411. return nil unless date_string
  412. DateTime.parse(date_string)
  413. rescue ArgumentError
  414. nil
  415. end
  416. # Extracts both plain text and HTML body content from email
  417. #
  418. # @param message [Google::Apis::GmailV1::Message]
  419. # @return [Hash] { plain: String, html: String|nil }
  420. def extract_body_content(message)
  421. result = { plain: message.snippet.to_s, html: nil }
  422. return result unless message.payload
  423. # Extract raw HTML body (stored as-is for rendering)
  424. html_body = extract_body_part(message.payload, "text/html")
  425. if html_body.present?
  426. # Store HTML as-is but limit size to prevent huge emails
  427. result[:html] = html_body.truncate(100_000, omission: "") # 100KB limit for HTML
  428. end
  429. # Extract plain text for preview and search
  430. plain_body = extract_body_part(message.payload, "text/plain")
  431. if plain_body.present?
  432. result[:plain] = clean_plain_text(plain_body)
  433. elsif html_body.present?
  434. # Convert HTML to plain text if no plain text version exists
  435. result[:plain] = clean_plain_text(ActionController::Base.helpers.strip_tags(html_body))
  436. end
  437. result
  438. end
  439. # Cleans up plain text content for storage
  440. #
  441. # @param text [String] Raw plain text
  442. # @return [String] Cleaned text
  443. def clean_plain_text(text)
  444. return "" if text.blank?
  445. text.gsub(/\r\n?/, "\n") # Normalize line endings
  446. .gsub(/\n{3,}/, "\n\n") # Max 2 consecutive newlines
  447. .gsub(/[ \t]+/, " ") # Collapse horizontal whitespace
  448. .strip
  449. .truncate(10_000) # Store up to 10KB of plain text
  450. end
  451. # Recursively extracts body part by MIME type
  452. #
  453. # The Google APIs gem may return body.data in two formats:
  454. # 1. Already decoded plain text (most common with format='full')
  455. # 2. Base64 encoded (URL-safe variant) which needs decoding
  456. #
  457. # @param part [Google::Apis::GmailV1::MessagePart]
  458. # @param mime_type [String]
  459. # @return [String, nil]
  460. def extract_body_part(part, mime_type)
  461. if part.mime_type == mime_type && part.body&.data.present?
  462. return decode_body_data(part.body.data)
  463. end
  464. return nil unless part.parts
  465. part.parts.each do |sub_part|
  466. result = extract_body_part(sub_part, mime_type)
  467. return result if result
  468. end
  469. nil
  470. rescue StandardError => e
  471. Rails.logger.error "Failed to extract body part (#{mime_type}): #{e.class} - #{e.message}"
  472. notify_error(
  473. e,
  474. context: "gmail_sync",
  475. severity: "warning",
  476. user: user,
  477. operation: "extract_body_part",
  478. mime_type: mime_type
  479. )
  480. nil
  481. end
  482. # Decodes body data from Gmail API
  483. #
  484. # The Gmail API gem sometimes returns already-decoded data and sometimes
  485. # returns Base64 encoded data. This method handles both cases.
  486. #
  487. # @param data [String] The body data (may be decoded or Base64 encoded)
  488. # @return [String, nil] The decoded content
  489. def decode_body_data(data)
  490. return nil if data.blank?
  491. # Check if data looks like Base64 (only contains valid Base64 chars)
  492. # Base64 URL-safe uses: A-Z, a-z, 0-9, -, _, and optional = padding
  493. if data.match?(/\A[A-Za-z0-9_-]+={0,2}\z/) && data.length > 50
  494. # Likely Base64 encoded - try to decode
  495. begin
  496. decoded = Base64.urlsafe_decode64(data)
  497. # Verify it produced valid UTF-8 text
  498. decoded.force_encoding("UTF-8")
  499. return decoded if decoded.valid_encoding?
  500. rescue ArgumentError
  501. # Not valid Base64, fall through to use raw data
  502. end
  503. end
  504. # Data is already decoded plain text or HTML
  505. # Force UTF-8 encoding and handle any invalid bytes
  506. data.dup.force_encoding("UTF-8").scrub
  507. end
  508. # Stores parsed emails and processes them
  509. #
  510. # @param parsed_emails [Array<Hash>] Parsed email data
  511. # @return [Hash] Processing statistics
  512. def store_and_process_emails(parsed_emails)
  513. stats = { new_count: 0, processed_count: 0, matched_count: 0, opportunities_count: 0, auto_ignored_count: 0 }
  514. parsed_emails.each do |email_data|
  515. # Create or find existing synced email
  516. synced_email = SyncedEmail.create_from_gmail_message(user, connected_account, email_data)
  517. next unless synced_email
  518. # Track if this is a new email
  519. # NOTE: We intentionally rely on the record's create/save changes instead of
  520. # created_at comparisons. Gmail sync can take longer than a minute, and we
  521. # also may see the same message again within a minute (which would otherwise
  522. # create confusing "no-op" pipeline runs with only synced_email_upsert).
  523. is_new_email = synced_email.previous_changes.key?("id")
  524. stats[:new_count] += 1 if is_new_email
  525. recorder =
  526. if is_new_email
  527. Signals::Observability::EmailPipelineRecorder.start_for(
  528. synced_email: synced_email,
  529. user: user,
  530. connected_account: connected_account,
  531. trigger: "gmail_sync",
  532. mode: "mixed",
  533. metadata: {
  534. "feature_flags" => {
  535. "signals_decision_shadow_enabled" => Setting.signals_decision_shadow_enabled?,
  536. "signals_decision_execution_enabled" => Setting.signals_decision_execution_enabled?,
  537. "signals_email_facts_extraction_enabled" => Setting.signals_email_facts_extraction_enabled?
  538. }
  539. }
  540. )
  541. end
  542. recorder&.event!(
  543. event_type: :synced_email_upsert,
  544. status: :success,
  545. output_payload: {
  546. "synced_email_id" => synced_email.id,
  547. "status" => synced_email.status,
  548. "extraction_status" => synced_email.extraction_status
  549. }
  550. )
  551. # Auto-ignore clearly irrelevant emails (marketing, notifications, etc.)
  552. if is_new_email && synced_email.pending? && clearly_irrelevant?(synced_email)
  553. synced_email.update!(status: :auto_ignored)
  554. stats[:auto_ignored_count] += 1
  555. recorder&.finish_success!(metadata: { "final" => "auto_ignored", "reason" => "clearly_irrelevant" })
  556. next
  557. end
  558. # Process the email if it's pending
  559. if synced_email.pending?
  560. result = Gmail::EmailProcessorService.new(synced_email, pipeline_run: recorder&.run).run
  561. stats[:processed_count] += 1 if result[:success]
  562. # Auto-ignore emails classified as "other" (not job-related)
  563. if result[:email_type] == "other" && !synced_email.matched?
  564. synced_email.update!(status: :auto_ignored)
  565. stats[:auto_ignored_count] += 1
  566. recorder&.finish_success!(metadata: { "final" => "auto_ignored", "reason" => "classified_other_unmatched" })
  567. next
  568. end
  569. # Check for recruiter outreach and create opportunity
  570. if result[:email_type] == "recruiter_outreach" && synced_email.opportunity.blank?
  571. unless Setting.signals_decision_opportunity_creation_enabled?
  572. opportunity = create_opportunity_from_email(synced_email)
  573. stats[:opportunities_count] += 1 if opportunity
  574. end
  575. elsif synced_email.reload.matched?
  576. stats[:matched_count] += 1
  577. end
  578. # Queue signal extraction for relevant emails
  579. if is_new_email
  580. queued = queue_signal_extraction(synced_email, run_id: recorder&.run&.id)
  581. if queued
  582. recorder&.event!(
  583. event_type: :signal_extraction_enqueued,
  584. status: :success,
  585. output_payload: { "job" => "ProcessSignalExtractionJob", "synced_email_id" => synced_email.id }
  586. )
  587. else
  588. recorder&.finish_success!(metadata: { "final" => "no_job_enqueued" })
  589. end
  590. else
  591. recorder&.finish_success!(metadata: { "final" => "processed_existing" }) if recorder
  592. end
  593. elsif synced_email.matched?
  594. stats[:matched_count] += 1
  595. recorder&.finish_success!(metadata: { "final" => "matched_existing" }) if recorder
  596. end
  597. end
  598. stats
  599. end
  600. # Checks if an email is clearly irrelevant (marketing, notifications, etc.)
  601. # These are platform emails that slipped through the Gmail query but aren't direct recruiter contact
  602. #
  603. # @param synced_email [SyncedEmail] The email to check
  604. # @return [Boolean] True if the email should be auto-ignored
  605. def clearly_irrelevant?(synced_email)
  606. # NEVER auto-ignore emails from companies user has applied to or is targeting
  607. # These are always relevant regardless of content patterns
  608. return false if from_user_company?(synced_email)
  609. content = [
  610. synced_email.subject,
  611. synced_email.snippet,
  612. synced_email.body_preview
  613. ].compact.join(" ")
  614. # Check against irrelevant patterns (newsletters, notifications, etc.)
  615. return true if IRRELEVANT_PATTERNS.any? { |pattern| content.match?(pattern) }
  616. # Check if subject matches notification-style subjects
  617. return true if NOTIFICATION_SUBJECTS.any? { |pattern| synced_email.subject&.match?(pattern) }
  618. # LinkedIn-specific: ignore if from LinkedIn but not a direct recruiter message
  619. if synced_email.from_email.include?("linkedin.com")
  620. # These are typically automated notifications, not direct recruiter outreach
  621. linkedin_notification_patterns = [
  622. /jobs-noreply/i,
  623. /messages-noreply/i,
  624. /notifications-noreply/i,
  625. /invitations/i,
  626. /member@linkedin/i
  627. ]
  628. return true if linkedin_notification_patterns.any? { |p| synced_email.from_email.match?(p) }
  629. end
  630. # Indeed/ZipRecruiter job alerts (not direct applications)
  631. if synced_email.from_email.match?(/indeed|ziprecruiter/i)
  632. return true if synced_email.subject&.match?(/jobs?\s+(for\s+you|matching|alert|digest)/i)
  633. end
  634. false
  635. end
  636. # Checks if an email is from a company the user has applied to or is targeting
  637. #
  638. # @param synced_email [SyncedEmail] The email to check
  639. # @return [Boolean] True if from a user's company
  640. def from_user_company?(synced_email)
  641. return false if synced_email.from_email.blank?
  642. sender_domain = synced_email.from_email.split("@").last&.downcase
  643. return false if sender_domain.blank?
  644. # Check if sender domain matches any user company domain
  645. user_company_email_domains.any? do |company_domain|
  646. # Match if domains are equal or one contains the other
  647. # (handles cases like "mail.google.com" vs "google.com")
  648. sender_domain == company_domain ||
  649. sender_domain.end_with?(".#{company_domain}") ||
  650. company_domain.end_with?(".#{sender_domain}")
  651. end
  652. end
  653. # Queues signal extraction for a synced email
  654. # Extracts company info, recruiter details, job information, and suggested actions
  655. #
  656. # @param synced_email [SyncedEmail] The synced email
  657. # @return [void]
  658. def queue_signal_extraction(synced_email, run_id: nil)
  659. # Only queue if email is suitable for extraction
  660. return false if synced_email.auto_ignored? || synced_email.ignored?
  661. return false if synced_email.email_type == "other" && !synced_email.matched?
  662. return false if synced_email.extraction_status != "pending"
  663. ProcessSignalExtractionJob.perform_later(synced_email.id, run_id)
  664. true
  665. end
  666. # Creates an opportunity from a recruiter outreach email
  667. #
  668. # @param synced_email [SyncedEmail] The synced email
  669. # @return [Opportunity, nil] Created opportunity or nil
  670. def create_opportunity_from_email(synced_email)
  671. return nil if synced_email.opportunity.present?
  672. detector = Gmail::OpportunityDetectorService.new(synced_email)
  673. opportunity = detector.create_opportunity!
  674. # Queue background job for AI extraction
  675. ProcessOpportunityEmailJob.perform_later(opportunity.id)
  676. opportunity
  677. rescue StandardError => e
  678. Rails.logger.error "Failed to create opportunity from email #{synced_email.id}: #{e.class} - #{e.message}"
  679. notify_error(
  680. e,
  681. context: "gmail_sync",
  682. severity: "error",
  683. user: user,
  684. operation: "create_opportunity",
  685. synced_email_id: synced_email.id
  686. )
  687. nil
  688. end
  689. end

app/services/interview_prep/base_generator_service.rb

0.0% lines covered

100.0% branches covered

133 relevant lines. 0 lines covered and 133 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module InterviewPrep
  3. # Base class for LLM-backed prep generation with provider fallback and logging.
  4. class BaseGeneratorService < ApplicationService
  5. # @param user [User]
  6. # @param interview_application [InterviewApplication]
  7. def initialize(user:, interview_application:)
  8. @user = user
  9. @application = interview_application
  10. @inputs_builder = InterviewPrep::InputsBuilderService.new(user: user, interview_application: interview_application)
  11. end
  12. # Generates and persists the artifact for this generator's kind.
  13. #
  14. # @return [InterviewPrepArtifact]
  15. def call
  16. artifact = find_or_build_artifact
  17. digest = inputs_builder.digest_for(kind)
  18. if artifact.status == "computed" && artifact.inputs_digest == digest
  19. return artifact
  20. end
  21. artifact.assign_attributes(status: :pending, inputs_digest: digest, error_message: nil)
  22. artifact.save!
  23. inputs = inputs_builder.build
  24. prompt = build_prompt(inputs)
  25. result = run_with_providers(prompt)
  26. if result[:success]
  27. artifact.assign_attributes(
  28. status: :computed,
  29. computed_at: Time.current,
  30. content: result[:content],
  31. provider: result[:provider],
  32. model: result[:model],
  33. llm_api_log_id: result[:llm_api_log_id]
  34. )
  35. else
  36. artifact.assign_attributes(
  37. status: :failed,
  38. computed_at: Time.current,
  39. error_message: result[:error].to_s,
  40. content: {}
  41. )
  42. end
  43. artifact.save!
  44. artifact
  45. end
  46. private
  47. attr_reader :user, :application, :inputs_builder
  48. # @return [Symbol]
  49. def kind
  50. raise NotImplementedError, "#{self.class} must implement #kind"
  51. end
  52. # @return [Ai::LlmPrompt, nil]
  53. def prompt_class
  54. raise NotImplementedError, "#{self.class} must implement #prompt_class"
  55. end
  56. # @return [String] operation_type for Ai::ApiLoggerService
  57. def operation_type
  58. "interview_prep_#{kind}"
  59. end
  60. def find_or_build_artifact
  61. InterviewPrepArtifact.find_or_initialize_by(interview_application: application, kind: kind).tap do |a|
  62. a.user ||= user
  63. end
  64. end
  65. def build_prompt(inputs)
  66. vars = {
  67. candidate_profile: JSON.generate(inputs[:candidate_profile]),
  68. job_context: JSON.generate(inputs[:job_context]),
  69. interview_stage: inputs[:interview_stage].to_s,
  70. feedback_themes: JSON.generate(inputs[:feedback_themes])
  71. }
  72. Ai::PromptBuilderService.new(
  73. prompt_class: prompt_class,
  74. variables: vars
  75. ).run
  76. end
  77. def provider_chain
  78. LlmProviders::ProviderConfigHelper.all_providers
  79. end
  80. def run_with_providers(prompt)
  81. template_record = prompt_class.active_prompt
  82. system_message =
  83. template_record&.system_prompt.presence ||
  84. (prompt_class.respond_to?(:default_system_prompt) ? prompt_class.default_system_prompt : nil)
  85. runner = Ai::ProviderRunnerService.new(
  86. provider_chain: provider_chain,
  87. prompt: prompt,
  88. content_size: prompt.bytesize,
  89. system_message: system_message,
  90. provider_for: method(:provider_for),
  91. logger_builder: lambda { |provider_name, provider|
  92. Ai::ApiLoggerService.new(
  93. operation_type: operation_type,
  94. loggable: application,
  95. provider: provider_name,
  96. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  97. llm_prompt: template_record
  98. )
  99. },
  100. operation: operation_type,
  101. loggable: application,
  102. user: user,
  103. error_context: {
  104. severity: "warning",
  105. application_id: application&.id
  106. }
  107. )
  108. result = runner.run do |response|
  109. parsed = parse_json(response[:content])
  110. normalized = normalize_parsed(parsed)
  111. log_data = normalized.merge(
  112. extracted_fields: normalized.keys.map(&:to_s)
  113. )
  114. [ normalized, log_data, true ]
  115. end
  116. return { success: false, error: result[:error] } unless result[:success]
  117. {
  118. success: true,
  119. content: result[:parsed],
  120. provider: result[:provider],
  121. model: result[:model],
  122. llm_api_log_id: result[:llm_api_log_id]
  123. }
  124. end
  125. def provider_for(provider_name)
  126. case provider_name.to_s.downcase
  127. when "openai" then LlmProviders::OpenaiProvider.new
  128. when "anthropic" then LlmProviders::AnthropicProvider.new
  129. when "ollama" then LlmProviders::OllamaProvider.new
  130. else
  131. raise ArgumentError, "Unknown provider: #{provider_name}"
  132. end
  133. end
  134. def parse_json(text)
  135. parsed = Ai::ResponseParserService.new(text).parse
  136. raise "No JSON found" unless parsed
  137. parsed
  138. end
  139. # Subclasses can override to enforce schema/shape.
  140. def normalize_parsed(parsed)
  141. parsed.is_a?(Hash) ? parsed : {}
  142. end
  143. end
  144. end

app/services/interview_prep/generate_focus_areas_service.rb

0.0% lines covered

100.0% branches covered

24 relevant lines. 0 lines covered and 24 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module InterviewPrep
  3. class GenerateFocusAreasService < BaseGeneratorService
  4. private
  5. def kind
  6. :focus_areas
  7. end
  8. def prompt_class
  9. Ai::InterviewPrepFocusAreasPrompt
  10. end
  11. def normalize_parsed(parsed)
  12. items = Array(parsed.is_a?(Hash) ? parsed["focus_areas"] : nil)
  13. focus_areas = items.map do |item|
  14. next unless item.is_a?(Hash)
  15. {
  16. title: item["title"].to_s.strip,
  17. why_it_matters: item["why_it_matters"].to_s.strip,
  18. how_to_prepare: Array(item["how_to_prepare"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8),
  19. experiences_to_use: Array(item["experiences_to_use"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8)
  20. }.compact
  21. end.compact
  22. { focus_areas: focus_areas.first(6) }
  23. end
  24. end
  25. end

app/services/interview_prep/generate_match_analysis_service.rb

0.0% lines covered

100.0% branches covered

21 relevant lines. 0 lines covered and 21 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module InterviewPrep
  3. class GenerateMatchAnalysisService < BaseGeneratorService
  4. private
  5. def kind
  6. :match_analysis
  7. end
  8. def prompt_class
  9. Ai::InterviewPrepMatchPrompt
  10. end
  11. def normalize_parsed(parsed)
  12. return {} unless parsed.is_a?(Hash)
  13. {
  14. match_label: parsed["match_label"].to_s,
  15. strong_in: Array(parsed["strong_in"]).map(&:to_s).map(&:strip).reject(&:blank?).first(10),
  16. partial_in: Array(parsed["partial_in"]).map(&:to_s).map(&:strip).reject(&:blank?).first(10),
  17. missing_or_risky: Array(parsed["missing_or_risky"]).map(&:to_s).map(&:strip).reject(&:blank?).first(10),
  18. notes: parsed["notes"].to_s.truncate(600)
  19. }
  20. end
  21. end
  22. end

app/services/interview_prep/generate_question_framing_service.rb

0.0% lines covered

100.0% branches covered

26 relevant lines. 0 lines covered and 26 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module InterviewPrep
  3. class GenerateQuestionFramingService < BaseGeneratorService
  4. private
  5. def kind
  6. :question_framing
  7. end
  8. def prompt_class
  9. Ai::InterviewPrepQuestionFramingPrompt
  10. end
  11. def normalize_parsed(parsed)
  12. items = Array(parsed.is_a?(Hash) ? parsed["questions"] : nil)
  13. questions = items.map do |item|
  14. next unless item.is_a?(Hash)
  15. q = item["question"].to_s.strip
  16. next if q.blank?
  17. {
  18. question: q,
  19. framing: Array(item["framing"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8),
  20. outline: Array(item["outline"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8),
  21. pitfalls: Array(item["pitfalls"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8)
  22. }
  23. end.compact
  24. { questions: questions.first(12) }
  25. end
  26. end
  27. end

app/services/interview_prep/generate_strength_positioning_service.rb

0.0% lines covered

100.0% branches covered

25 relevant lines. 0 lines covered and 25 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module InterviewPrep
  3. class GenerateStrengthPositioningService < BaseGeneratorService
  4. private
  5. def kind
  6. :strength_positioning
  7. end
  8. def prompt_class
  9. Ai::InterviewPrepStrengthPositioningPrompt
  10. end
  11. def normalize_parsed(parsed)
  12. items = Array(parsed.is_a?(Hash) ? parsed["strengths"] : nil)
  13. strengths = items.map do |item|
  14. next unless item.is_a?(Hash)
  15. title = item["title"].to_s.strip
  16. next if title.blank?
  17. {
  18. title: title,
  19. positioning: item["positioning"].to_s.strip,
  20. evidence_types: Array(item["evidence_types"]).map(&:to_s).map(&:strip).reject(&:blank?).first(8)
  21. }
  22. end.compact
  23. { strengths: strengths.first(10) }
  24. end
  25. end
  26. end

app/services/interview_prep/inputs_builder_service.rb

0.0% lines covered

100.0% branches covered

102 relevant lines. 0 lines covered and 102 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "digest"
  3. module InterviewPrep
  4. # Builds normalized inputs for interview prep generation and computes digests for caching.
  5. class InputsBuilderService
  6. ALGORITHM_VERSION = "v1_job_listing_primary"
  7. # @param user [User]
  8. # @param interview_application [InterviewApplication]
  9. def initialize(user:, interview_application:)
  10. @user = user
  11. @application = interview_application
  12. end
  13. # Returns a hash of inputs used by all prep generators.
  14. #
  15. # @return [Hash]
  16. def build
  17. {
  18. algorithm_version: ALGORITHM_VERSION,
  19. candidate_profile: candidate_profile,
  20. job_context: job_context,
  21. interview_stage: interview_stage,
  22. feedback_themes: feedback_themes
  23. }
  24. end
  25. # Computes an idempotency digest for a specific artifact kind.
  26. #
  27. # @param kind [String, Symbol]
  28. # @return [String]
  29. def digest_for(kind)
  30. payload = build.merge(kind: kind.to_s)
  31. Digest::SHA256.hexdigest(JSON.generate(payload))
  32. end
  33. private
  34. attr_reader :user, :application
  35. def candidate_profile
  36. resume = user.user_resumes.analyzed.recent_first.first
  37. top_skills = user.top_skills(limit: 15).includes(:skill_tag).map do |us|
  38. {
  39. skill: us.skill_tag&.name,
  40. aggregated_level: us.aggregated_level&.round(2),
  41. category: us.category
  42. }.compact
  43. end
  44. {
  45. user: {
  46. id: user.id,
  47. name: user.name,
  48. years_of_experience: user.years_of_experience,
  49. current_company: user.current_company&.name,
  50. current_job_role: user.current_job_role&.title
  51. },
  52. resume: {
  53. id: resume&.id,
  54. analyzed_at: resume&.analyzed_at,
  55. summary: resume&.analysis_summary,
  56. strengths: Array(resume&.strengths),
  57. domains: Array(resume&.domains)
  58. },
  59. top_skills: top_skills
  60. }
  61. end
  62. # JobListing is the default source of truth.
  63. # Manual job_description_text is supplemental fallback/extra context.
  64. def job_context
  65. jl = application.job_listing
  66. extracted = if jl
  67. {
  68. title: jl.display_title,
  69. url: jl.url,
  70. location: jl.location_display,
  71. salary_range: jl.salary_range,
  72. description: jl.description,
  73. responsibilities: jl.responsibilities,
  74. requirements: jl.requirements,
  75. about_company: jl.about_company,
  76. company_culture: jl.company_culture,
  77. benefits: jl.benefits,
  78. perks: jl.perks,
  79. custom_sections: jl.custom_sections
  80. }.compact
  81. else
  82. {}
  83. end
  84. {
  85. company: application.display_company&.name,
  86. role: application.display_job_role&.title,
  87. extracted_job_listing: extracted,
  88. supplemental_job_text: application.job_description_text.presence
  89. }.compact
  90. end
  91. def interview_stage
  92. next_round = application.interview_rounds.upcoming.order(scheduled_at: :asc).first
  93. return next_round.stage.to_s if next_round&.stage.present?
  94. # Fallback mapping from pipeline stage
  95. case application.pipeline_stage&.to_sym
  96. when :screening then "screening"
  97. when :interviewing then "technical"
  98. when :offer then "hiring_manager"
  99. else "screening"
  100. end
  101. end
  102. def feedback_themes
  103. feedbacks = InterviewFeedback
  104. .joins(interview_round: { interview_application: :user })
  105. .where(users: { id: user.id })
  106. .recent
  107. .limit(50)
  108. tags = feedbacks.flat_map(&:tag_list).map(&:to_s).map(&:strip).reject(&:blank?)
  109. tag_counts = tags.each_with_object(Hash.new(0)) { |t, h| h[t] += 1 }
  110. {
  111. top_tags: tag_counts.sort_by { |_k, v| -v }.first(10).map { |k, v| { tag: k, count: v } },
  112. notes: "Derived from your recent self-reflections (tags only)."
  113. }
  114. end
  115. end
  116. end

app/services/interview_round_prep/company_patterns_service.rb

0.0% lines covered

100.0% branches covered

118 relevant lines. 0 lines covered and 118 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module InterviewRoundPrep
  3. # Aggregates company-specific interview patterns from historical data.
  4. #
  5. # Analyzes interview data from all users who interviewed at the same company
  6. # to identify:
  7. # - Common round sequences
  8. # - Typical interview formats and durations
  9. # - Success factors
  10. # - Question themes (anonymized)
  11. #
  12. # @example
  13. # service = InterviewRoundPrep::CompanyPatternsService.new(
  14. # company: company,
  15. # round_type: round_type
  16. # )
  17. # patterns = service.analyze
  18. class CompanyPatternsService < ApplicationService
  19. # @param company [Company]
  20. # @param round_type [InterviewRoundType, nil]
  21. def initialize(company:, round_type:)
  22. @company = company
  23. @round_type = round_type
  24. end
  25. # Analyzes company interview patterns
  26. #
  27. # @return [Hash]
  28. def analyze
  29. return empty_analysis if company.nil? || company_rounds.empty?
  30. {
  31. company_name: company.name,
  32. total_interviews: company_applications.count,
  33. round_type_data: round_type_patterns,
  34. typical_process: typical_interview_process,
  35. success_indicators: success_indicators,
  36. average_duration_minutes: average_duration,
  37. interview_style_hints: interview_style_hints
  38. }.compact
  39. end
  40. private
  41. attr_reader :company, :round_type
  42. # @return [ActiveRecord::Relation]
  43. def company_applications
  44. @company_applications ||= InterviewApplication.where(company: company)
  45. end
  46. # @return [ActiveRecord::Relation]
  47. def company_rounds
  48. @company_rounds ||= InterviewRound
  49. .joins(:interview_application)
  50. .where(interview_applications: { company_id: company.id })
  51. end
  52. # @return [ActiveRecord::Relation]
  53. def type_specific_rounds
  54. return company_rounds unless round_type
  55. @type_specific_rounds ||= company_rounds.where(interview_round_type_id: round_type.id)
  56. end
  57. # Patterns specific to the round type at this company
  58. #
  59. # @return [Hash, nil]
  60. def round_type_patterns
  61. return nil unless round_type
  62. type_rounds = type_specific_rounds
  63. return nil if type_rounds.empty?
  64. completed = type_rounds.where.not(completed_at: nil)
  65. passed = completed.where(result: :passed)
  66. {
  67. round_type_name: round_type.name,
  68. total_at_company: type_rounds.count,
  69. pass_rate: completed.any? ? (passed.count.to_f / completed.count * 100).round(1) : nil,
  70. common_position: most_common_position(type_rounds)
  71. }.compact
  72. end
  73. # Identifies the typical interview process at this company
  74. #
  75. # @return [Hash]
  76. def typical_interview_process
  77. # Count rounds per application
  78. round_counts = company_applications
  79. .joins(:interview_rounds)
  80. .group("interview_applications.id")
  81. .count("interview_rounds.id")
  82. avg_rounds = round_counts.values.any? ? (round_counts.values.sum.to_f / round_counts.size).round(1) : nil
  83. # Common stage sequence
  84. stage_sequence = company_rounds
  85. .order(:position)
  86. .pluck(:stage)
  87. .uniq
  88. {
  89. average_rounds: avg_rounds,
  90. typical_stages: stage_sequence.first(6),
  91. total_applications_analyzed: company_applications.count
  92. }.compact
  93. end
  94. # Identifies success indicators from applications that received offers
  95. #
  96. # @return [Hash]
  97. def success_indicators
  98. successful_apps = company_applications.where(pipeline_stage: :offer)
  99. return nil if successful_apps.empty?
  100. successful_rounds = InterviewRound
  101. .joins(:interview_application)
  102. .where(interview_applications: { id: successful_apps.select(:id) })
  103. .where.not(completed_at: nil)
  104. # Common tags from successful interview feedback
  105. feedbacks = InterviewFeedback
  106. .joins(interview_round: :interview_application)
  107. .where(interview_applications: { id: successful_apps.select(:id) })
  108. success_tags = feedbacks.flat_map(&:tag_list).map(&:to_s).map(&:strip).reject(&:blank?)
  109. tag_counts = success_tags.each_with_object(Hash.new(0)) { |t, h| h[t] += 1 }
  110. top_tags = tag_counts.sort_by { |_k, v| -v }.first(5).map { |tag, _| tag }
  111. {
  112. successful_applications: successful_apps.count,
  113. success_rate: (successful_apps.count.to_f / company_applications.count * 100).round(1),
  114. common_success_factors: top_tags
  115. }.compact
  116. end
  117. # @return [Integer, nil]
  118. def average_duration
  119. durations = type_specific_rounds.where.not(duration_minutes: nil).pluck(:duration_minutes)
  120. return nil if durations.empty?
  121. (durations.sum.to_f / durations.size).round
  122. end
  123. # Infers interview style from available data
  124. #
  125. # @return [Array<String>]
  126. def interview_style_hints
  127. hints = []
  128. # Check for video links (remote interviews)
  129. video_count = type_specific_rounds.where.not(video_link: nil).count
  130. if video_count > type_specific_rounds.count / 2
  131. hints << "Often conducted remotely"
  132. end
  133. # Check for multiple interviewer mentions
  134. panel_rounds = type_specific_rounds.where("interview_rounds.notes ILIKE ?", "%panel%")
  135. if panel_rounds.any?
  136. hints << "May include panel interviews"
  137. end
  138. # Check typical duration patterns
  139. avg_dur = average_duration
  140. if avg_dur
  141. if avg_dur >= 60
  142. hints << "Typically extended sessions (60+ min)"
  143. elsif avg_dur <= 30
  144. hints << "Usually quick sessions (30 min or less)"
  145. end
  146. end
  147. hints
  148. end
  149. # @param rounds [ActiveRecord::Relation]
  150. # @return [Integer, nil]
  151. def most_common_position(rounds)
  152. positions = rounds.where.not(position: nil).pluck(:position)
  153. return nil if positions.empty?
  154. positions.group_by(&:itself).max_by { |_, v| v.size }&.first
  155. end
  156. # @return [Hash]
  157. def empty_analysis
  158. {
  159. note: "Limited company data available",
  160. total_interviews: 0
  161. }
  162. end
  163. end
  164. end

app/services/interview_round_prep/generate_service.rb

0.0% lines covered

100.0% branches covered

123 relevant lines. 0 lines covered and 123 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module InterviewRoundPrep
  3. # Orchestrates round-specific interview prep generation using LLM providers.
  4. #
  5. # Builds comprehensive prep content including:
  6. # - Round summary and format hints
  7. # - Expected questions tailored to round type
  8. # - Historical performance analysis
  9. # - Company-specific patterns
  10. # - Preparation checklist
  11. #
  12. # @example
  13. # service = InterviewRoundPrep::GenerateService.new(interview_round: round)
  14. # artifact = service.call
  15. class GenerateService < ApplicationService
  16. # @param interview_round [InterviewRound]
  17. # @param force [Boolean] Force regeneration even if artifact exists
  18. def initialize(interview_round:, force: false)
  19. @round = interview_round
  20. @force = force
  21. @inputs_builder = InputsBuilderService.new(interview_round: round)
  22. end
  23. # Generates and persists the prep artifact.
  24. #
  25. # @return [InterviewRoundPrepArtifact]
  26. def call
  27. artifact = find_or_build_artifact
  28. digest = inputs_builder.digest_for(:comprehensive)
  29. # Return cached if valid and not forcing regeneration
  30. if !force && artifact.persisted? && artifact.completed? && artifact.inputs_digest == digest
  31. return artifact
  32. end
  33. # Mark as generating
  34. artifact.assign_attributes(status: :generating, inputs_digest: digest)
  35. artifact.save!
  36. # Build inputs and generate
  37. inputs = inputs_builder.build
  38. prompt = build_prompt(inputs)
  39. result = run_with_providers(prompt)
  40. if result[:success]
  41. artifact.complete!(result[:content], digest: digest)
  42. else
  43. artifact.fail!(result[:error])
  44. end
  45. artifact
  46. end
  47. private
  48. attr_reader :round, :force, :inputs_builder
  49. # @return [InterviewRoundPrepArtifact]
  50. def find_or_build_artifact
  51. InterviewRoundPrepArtifact.find_or_initialize_for(
  52. interview_round: round,
  53. kind: :comprehensive
  54. )
  55. end
  56. # @return [Ai::RoundPrepPrompt, nil]
  57. def prompt_class
  58. Ai::RoundPrepPrompt
  59. end
  60. # @return [String]
  61. def operation_type
  62. "round_prep_comprehensive"
  63. end
  64. # @return [String]
  65. def build_prompt(inputs)
  66. vars = {
  67. round_context: JSON.pretty_generate(inputs[:round_context]),
  68. job_context: JSON.pretty_generate(inputs[:job_context]),
  69. candidate_profile: JSON.pretty_generate(inputs[:candidate_profile]),
  70. historical_performance: JSON.pretty_generate(inputs[:historical_performance]),
  71. company_patterns: JSON.pretty_generate(inputs[:company_patterns])
  72. }
  73. Ai::PromptBuilderService.new(
  74. prompt_class: prompt_class,
  75. variables: vars
  76. ).run
  77. end
  78. # @return [Array<String>]
  79. def provider_chain
  80. LlmProviders::ProviderConfigHelper.all_providers
  81. end
  82. # @return [Hash]
  83. def run_with_providers(prompt)
  84. template_record = prompt_class.active_prompt
  85. system_message = template_record&.system_prompt.presence ||
  86. (prompt_class.respond_to?(:default_system_prompt) ? prompt_class.default_system_prompt : nil)
  87. runner = Ai::ProviderRunnerService.new(
  88. provider_chain: provider_chain,
  89. prompt: prompt,
  90. content_size: prompt.bytesize,
  91. system_message: system_message,
  92. provider_for: method(:provider_for),
  93. logger_builder: lambda { |provider_name, provider|
  94. Ai::ApiLoggerService.new(
  95. operation_type: operation_type,
  96. loggable: round,
  97. provider: provider_name,
  98. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  99. llm_prompt: template_record
  100. )
  101. },
  102. operation: operation_type,
  103. loggable: round,
  104. user: round.interview_application&.user,
  105. error_context: {
  106. severity: "warning",
  107. interview_round_id: round&.id
  108. }
  109. )
  110. result = runner.run do |response|
  111. parsed = parse_json(response[:content])
  112. normalized = normalize_content(parsed)
  113. [ normalized, normalized, true ]
  114. end
  115. return { success: false, error: result[:error] } unless result[:success]
  116. {
  117. success: true,
  118. content: result[:parsed],
  119. provider: result[:provider],
  120. model: result[:model]
  121. }
  122. end
  123. # @return [LlmProviders::BaseProvider]
  124. def provider_for(provider_name)
  125. case provider_name.to_s.downcase
  126. when "openai" then LlmProviders::OpenaiProvider.new
  127. when "anthropic" then LlmProviders::AnthropicProvider.new
  128. when "ollama" then LlmProviders::OllamaProvider.new
  129. else
  130. raise ArgumentError, "Unknown provider: #{provider_name}"
  131. end
  132. end
  133. # @return [Hash]
  134. def parse_json(text)
  135. parsed = Ai::ResponseParserService.new(text).parse
  136. raise "No JSON found in response" unless parsed
  137. parsed
  138. end
  139. # Normalizes the parsed content to expected schema
  140. #
  141. # @return [Hash]
  142. def normalize_content(parsed)
  143. return {} unless parsed.is_a?(Hash)
  144. {
  145. round_summary: parsed["round_summary"],
  146. expected_questions: Array(parsed["expected_questions"]),
  147. your_history: parsed["your_history"],
  148. company_patterns: parsed["company_patterns"],
  149. preparation_checklist: Array(parsed["preparation_checklist"]),
  150. answer_strategies: Array(parsed["answer_strategies"]),
  151. tips: Array(parsed["tips"])
  152. }.compact
  153. end
  154. end
  155. end

app/services/interview_round_prep/historical_analyzer_service.rb

0.0% lines covered

100.0% branches covered

114 relevant lines. 0 lines covered and 114 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module InterviewRoundPrep
  3. # Analyzes user's historical performance on similar interview round types.
  4. #
  5. # Examines past rounds to identify:
  6. # - Pass rate for this type of interview
  7. # - Common feedback themes (strengths and areas to improve)
  8. # - Time patterns (average duration, recent trends)
  9. #
  10. # @example
  11. # service = InterviewRoundPrep::HistoricalAnalyzerService.new(
  12. # user: user,
  13. # round_type: round_type
  14. # )
  15. # analysis = service.analyze
  16. class HistoricalAnalyzerService < ApplicationService
  17. # @param user [User]
  18. # @param round_type [InterviewRoundType, nil]
  19. def initialize(user:, round_type:)
  20. @user = user
  21. @round_type = round_type
  22. end
  23. # Analyzes historical performance
  24. #
  25. # @return [Hash]
  26. def analyze
  27. return empty_analysis if past_rounds.empty?
  28. {
  29. total_rounds: past_rounds.count,
  30. completed_rounds: completed_rounds.count,
  31. pass_rate: calculate_pass_rate,
  32. performance_trend: performance_trend,
  33. feedback_themes: feedback_themes,
  34. common_strengths: common_strengths,
  35. areas_to_improve: areas_to_improve,
  36. average_duration_minutes: average_duration
  37. }.compact
  38. end
  39. private
  40. attr_reader :user, :round_type
  41. # @return [ActiveRecord::Relation]
  42. def past_rounds
  43. @past_rounds ||= begin
  44. scope = InterviewRound
  45. .joins(interview_application: :user)
  46. .where(interview_applications: { user_id: user.id })
  47. if round_type
  48. scope = scope.where(interview_round_type_id: round_type.id)
  49. end
  50. scope.order(created_at: :desc).limit(50)
  51. end
  52. end
  53. # @return [ActiveRecord::Relation]
  54. def completed_rounds
  55. @completed_rounds ||= past_rounds.where.not(completed_at: nil)
  56. end
  57. # @return [Float, nil]
  58. def calculate_pass_rate
  59. return nil if completed_rounds.empty?
  60. passed = completed_rounds.where(result: :passed).count
  61. total = completed_rounds.count
  62. return nil if total.zero?
  63. (passed.to_f / total * 100).round(1)
  64. end
  65. # Analyzes recent performance trend
  66. #
  67. # @return [String, nil]
  68. def performance_trend
  69. recent = completed_rounds.limit(5)
  70. return nil if recent.count < 3
  71. recent_results = recent.pluck(:result)
  72. passed_count = recent_results.count("passed")
  73. if passed_count >= 4
  74. "strong"
  75. elsif passed_count >= 3
  76. "positive"
  77. elsif passed_count >= 2
  78. "mixed"
  79. else
  80. "needs_improvement"
  81. end
  82. end
  83. # Extracts feedback themes from past round feedback
  84. #
  85. # @return [Array<Hash>]
  86. def feedback_themes
  87. feedbacks = InterviewFeedback
  88. .joins(interview_round: :interview_application)
  89. .where(interview_applications: { user_id: user.id })
  90. if round_type
  91. feedbacks = feedbacks.joins(:interview_round)
  92. .where(interview_rounds: { interview_round_type_id: round_type.id })
  93. end
  94. feedbacks = feedbacks.order(created_at: :desc).limit(20)
  95. # Extract tags and count frequencies
  96. tags = feedbacks.flat_map(&:tag_list).map(&:to_s).map(&:strip).reject(&:blank?)
  97. tag_counts = tags.each_with_object(Hash.new(0)) { |t, h| h[t] += 1 }
  98. tag_counts.sort_by { |_k, v| -v }.first(10).map do |tag, count|
  99. { tag: tag, count: count }
  100. end
  101. end
  102. # Identifies common strengths from feedback
  103. #
  104. # @return [Array<String>]
  105. def common_strengths
  106. # Look for positive patterns in feedback went_well field
  107. feedbacks = InterviewFeedback
  108. .joins(interview_round: :interview_application)
  109. .where(interview_applications: { user_id: user.id })
  110. .where.not(went_well: [ nil, "" ])
  111. if round_type
  112. feedbacks = feedbacks.joins(:interview_round)
  113. .where(interview_rounds: { interview_round_type_id: round_type.id })
  114. end
  115. # Extract from tags that indicate strengths
  116. strength_tags = feedbacks.flat_map(&:tag_list)
  117. .map(&:to_s)
  118. .select { |t| t.match?(/strong|good|excellent|clear|effective/i) }
  119. strength_tags.uniq.first(5)
  120. end
  121. # Identifies areas that need improvement
  122. #
  123. # @return [Array<String>]
  124. def areas_to_improve
  125. # Look for patterns in to_improve field
  126. feedbacks = InterviewFeedback
  127. .joins(interview_round: :interview_application)
  128. .where(interview_applications: { user_id: user.id })
  129. .where.not(to_improve: [ nil, "" ])
  130. if round_type
  131. feedbacks = feedbacks.joins(:interview_round)
  132. .where(interview_rounds: { interview_round_type_id: round_type.id })
  133. end
  134. # Extract from tags that indicate improvement areas
  135. improvement_tags = feedbacks.flat_map(&:tag_list)
  136. .map(&:to_s)
  137. .reject { |t| t.match?(/strong|good|excellent/i) }
  138. improvement_tags.uniq.first(5)
  139. end
  140. # @return [Integer, nil]
  141. def average_duration
  142. durations = completed_rounds.where.not(duration_minutes: nil).pluck(:duration_minutes)
  143. return nil if durations.empty?
  144. (durations.sum.to_f / durations.size).round
  145. end
  146. # @return [Hash]
  147. def empty_analysis
  148. {
  149. total_rounds: 0,
  150. completed_rounds: 0,
  151. note: "No historical data available for this round type"
  152. }
  153. end
  154. end
  155. end

app/services/interview_round_prep/inputs_builder_service.rb

0.0% lines covered

100.0% branches covered

113 relevant lines. 0 lines covered and 113 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "digest"
  3. module InterviewRoundPrep
  4. # Builds normalized inputs for round-specific interview prep generation.
  5. #
  6. # Gathers context from:
  7. # - The interview round (type, stage, scheduled time, interviewer)
  8. # - The interview application (company, job role, job listing)
  9. # - User profile and history
  10. # - Historical performance on similar round types
  11. # - Company interview patterns
  12. #
  13. # @example
  14. # service = InterviewRoundPrep::InputsBuilderService.new(interview_round: round)
  15. # inputs = service.build
  16. # digest = service.digest_for(:comprehensive)
  17. class InputsBuilderService < ApplicationService
  18. ALGORITHM_VERSION = "v1_round_prep"
  19. # @param interview_round [InterviewRound]
  20. def initialize(interview_round:)
  21. @round = interview_round
  22. @application = interview_round.interview_application
  23. @user = @application.user
  24. end
  25. # Returns a hash of inputs used by round prep generators.
  26. #
  27. # @return [Hash]
  28. def build
  29. {
  30. algorithm_version: ALGORITHM_VERSION,
  31. round_context: round_context,
  32. job_context: job_context,
  33. candidate_profile: candidate_profile,
  34. historical_performance: historical_performance,
  35. company_patterns: company_patterns
  36. }
  37. end
  38. # Computes an idempotency digest for a specific artifact kind.
  39. #
  40. # @param kind [String, Symbol]
  41. # @return [String]
  42. def digest_for(kind)
  43. payload = build.merge(kind: kind.to_s)
  44. Digest::SHA256.hexdigest(JSON.generate(payload))
  45. end
  46. private
  47. attr_reader :round, :application, :user
  48. # Context about the specific interview round
  49. #
  50. # @return [Hash]
  51. def round_context
  52. {
  53. id: round.id,
  54. stage: round.stage,
  55. stage_name: round.stage_display_name,
  56. round_type: round_type_context,
  57. scheduled_at: round.scheduled_at&.iso8601,
  58. duration_minutes: round.duration_minutes,
  59. interviewer: {
  60. name: round.interviewer_name,
  61. role: round.interviewer_role
  62. }.compact.presence,
  63. notes: round.notes,
  64. position_in_process: round.position,
  65. has_video_link: round.has_video_link?
  66. }.compact
  67. end
  68. # Round type information
  69. #
  70. # @return [Hash, nil]
  71. def round_type_context
  72. return nil unless round.interview_round_type
  73. {
  74. name: round.interview_round_type.name,
  75. slug: round.interview_round_type.slug,
  76. description: round.interview_round_type.description,
  77. department: round.interview_round_type.department_name
  78. }.compact
  79. end
  80. # Context about the job application
  81. #
  82. # @return [Hash]
  83. def job_context
  84. jl = application.job_listing
  85. extracted = if jl
  86. {
  87. title: jl.display_title,
  88. url: jl.url,
  89. location: jl.location_display,
  90. salary_range: jl.salary_range,
  91. description: jl.description,
  92. responsibilities: jl.responsibilities,
  93. requirements: jl.requirements,
  94. about_company: jl.about_company,
  95. company_culture: jl.company_culture,
  96. custom_sections: jl.custom_sections
  97. }.compact
  98. else
  99. {}
  100. end
  101. {
  102. company: application.display_company&.name,
  103. company_id: application.company_id,
  104. role: application.display_job_role&.title,
  105. role_id: application.job_role_id,
  106. department: application.job_role&.department_name,
  107. extracted_job_listing: extracted.presence,
  108. supplemental_job_text: application.job_description_text.presence,
  109. pipeline_stage: application.pipeline_stage
  110. }.compact
  111. end
  112. # Candidate profile information
  113. #
  114. # @return [Hash]
  115. def candidate_profile
  116. resume = user.user_resumes.analyzed.recent_first.first
  117. top_skills = user.top_skills(limit: 10).includes(:skill_tag).map do |us|
  118. {
  119. skill: us.skill_tag&.name,
  120. level: us.aggregated_level&.round(2)
  121. }.compact
  122. end
  123. {
  124. name: user.name,
  125. years_of_experience: user.years_of_experience,
  126. current_role: user.current_job_role&.title,
  127. current_company: user.current_company&.name,
  128. resume_summary: resume&.analysis_summary,
  129. strengths: Array(resume&.strengths).first(5),
  130. top_skills: top_skills
  131. }.compact
  132. end
  133. # Historical performance on similar round types
  134. #
  135. # @return [Hash]
  136. def historical_performance
  137. safely(fallback: { note: "Historical data unavailable", error: true }) do
  138. HistoricalAnalyzerService.new(user: user, round_type: round.interview_round_type).analyze
  139. end
  140. end
  141. # Company-specific interview patterns
  142. #
  143. # @return [Hash]
  144. def company_patterns
  145. safely(fallback: { note: "Company pattern data unavailable", error: true }) do
  146. CompanyPatternsService.new(
  147. company: application.company,
  148. round_type: round.interview_round_type
  149. ).analyze
  150. end
  151. end
  152. end
  153. end

app/services/job_listing_scraper_service.rb

0.0% lines covered

100.0% branches covered

75 relevant lines. 0 lines covered and 75 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for scraping job listing information from URLs
  3. #
  4. # This service delegates to the Scraping::OrchestratorService for actual extraction.
  5. # It maintains backward compatibility with the existing interface while using
  6. # the new orchestration system under the hood.
  7. #
  8. # @example
  9. # service = JobListingScraperService.new(url: "https://company.com/jobs/123")
  10. # result = service.scrape
  11. # job_listing.update(scraped_data: result)
  12. #
  13. class JobListingScraperService
  14. # Initialize the service with a job listing
  15. #
  16. # @param [String] url The URL of the job listing (deprecated - use job_listing)
  17. # @param [JobListing] job_listing The job listing model to extract for
  18. def initialize(url: nil, job_listing: nil)
  19. if job_listing
  20. @job_listing = job_listing
  21. @url = job_listing.url
  22. elsif url
  23. # For backward compatibility - create a temporary job listing
  24. @url = url
  25. @job_listing = nil
  26. else
  27. raise ArgumentError, "Must provide either url or job_listing"
  28. end
  29. @scraped_at = Time.current
  30. end
  31. # Scrapes the job listing using the orchestration service
  32. #
  33. # @return [Hash] Scraped data including title, description, requirements, etc.
  34. def scrape
  35. # If we have a job listing model, use the orchestrator
  36. if @job_listing
  37. orchestrator = Scraping::OrchestratorService.new(@job_listing)
  38. success = orchestrator.call
  39. # Return scraped data format
  40. if success
  41. {
  42. scraped_at: @scraped_at.iso8601,
  43. source_url: @url,
  44. title: @job_listing.title,
  45. description: @job_listing.description,
  46. requirements: @job_listing.requirements,
  47. responsibilities: @job_listing.responsibilities,
  48. location: @job_listing.location,
  49. remote_type: @job_listing.remote_type,
  50. salary_min: @job_listing.salary_min,
  51. salary_max: @job_listing.salary_max,
  52. salary_currency: @job_listing.salary_currency,
  53. equity_info: @job_listing.equity_info,
  54. benefits: @job_listing.benefits,
  55. perks: @job_listing.perks,
  56. custom_sections: @job_listing.custom_sections,
  57. success: true,
  58. error: nil
  59. }
  60. else
  61. {
  62. scraped_at: @scraped_at.iso8601,
  63. source_url: @url,
  64. success: false,
  65. error: "Extraction failed - check scraping attempts for details"
  66. }
  67. end
  68. else
  69. # Fallback for URL-only usage (not recommended)
  70. Rails.logger.warn("JobListingScraperService called without job_listing model")
  71. {
  72. scraped_at: @scraped_at.iso8601,
  73. source_url: @url,
  74. success: false,
  75. error: "Job listing model required for extraction"
  76. }
  77. end
  78. end
  79. # Checks if the URL is scrapable
  80. #
  81. # @return [Boolean] True if URL can be scraped
  82. def scrapable?
  83. return false if @url.blank?
  84. # Check if URL is from supported job boards
  85. supported_domains.any? { |domain| @url.include?(domain) }
  86. end
  87. # Returns list of supported job board domains
  88. #
  89. # @return [Array<String>] List of supported domains
  90. def supported_domains
  91. [
  92. "linkedin.com",
  93. "indeed.com",
  94. "glassdoor.com",
  95. "greenhouse.io",
  96. "lever.co",
  97. "workable.com",
  98. "jobvite.com",
  99. "icims.com",
  100. "smartrecruiters.com",
  101. "bamboohr.com",
  102. "ashbyhq.com"
  103. ]
  104. end
  105. end

app/services/job_listings/upsert_from_url_service.rb

0.0% lines covered

100.0% branches covered

49 relevant lines. 0 lines covered and 49 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module JobListings
  3. # Service for finding or creating a JobListing from a URL.
  4. #
  5. # Normalizes the URL (removes common tracking params), and ensures the JobListing
  6. # has the required associations (company, job_role).
  7. #
  8. # This service intentionally does NOT enqueue scraping jobs; scraping is handled
  9. # by higher-level workflow orchestration (Signals decision execution, opportunity apply, etc.).
  10. class UpsertFromUrlService
  11. # @param url [String]
  12. # @param company [Company]
  13. # @param job_role [JobRole]
  14. # @param title [String, nil]
  15. def initialize(url:, company:, job_role:, title: nil)
  16. @url = url
  17. @company = company
  18. @job_role = job_role
  19. @title = title
  20. end
  21. # @return [Hash] { job_listing: JobListing, created: Boolean, normalized_url: String }
  22. def call
  23. raise ArgumentError, "url is required" if url.blank?
  24. raise ArgumentError, "company is required" if company.blank?
  25. raise ArgumentError, "job_role is required" if job_role.blank?
  26. normalized_url = normalize_url(url)
  27. existing = JobListing.find_by(url: normalized_url)
  28. return { job_listing: existing, created: false, normalized_url: normalized_url } if existing
  29. base_url = normalized_url.split("?").first
  30. if base_url.present? && base_url != normalized_url
  31. existing_base = JobListing.find_by(url: base_url)
  32. return { job_listing: existing_base, created: false, normalized_url: base_url } if existing_base
  33. end
  34. jl = JobListing.create!(
  35. url: normalized_url,
  36. company: company,
  37. job_role: job_role,
  38. title: title.presence || job_role.title,
  39. status: :active,
  40. source_id: extract_source_id(normalized_url)
  41. )
  42. { job_listing: jl, created: true, normalized_url: normalized_url }
  43. end
  44. private
  45. attr_reader :url, :company, :job_role, :title
  46. def extract_source_id(url)
  47. match = url.match(%r{/(jobs?|careers?|positions?)/([^/\?]+)})
  48. match ? match[2] : nil
  49. end
  50. def normalize_url(url)
  51. uri = URI.parse(url.strip)
  52. return url.strip unless uri.query.present?
  53. params = URI.decode_www_form(uri.query).reject do |key, _|
  54. %w[utm_source utm_medium utm_campaign utm_content utm_term ref source].include?(key.downcase)
  55. end
  56. uri.query = params.any? ? URI.encode_www_form(params) : nil
  57. uri.to_s
  58. rescue URI::InvalidURIError
  59. url.strip
  60. end
  61. end
  62. end

app/services/labels/dedupe_service.rb

0.0% lines covered

100.0% branches covered

89 relevant lines. 0 lines covered and 89 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for de-duplicating "label" style lists (e.g., strengths, domains) where the
  3. # source may contain near-duplicates (punctuation/wording differences).
  4. #
  5. # We intentionally keep this conservative: it groups items only when they have
  6. # very high token overlap, so we don't accidentally merge distinct concepts.
  7. #
  8. # @example
  9. # groups = Labels::DedupeService.new(labels, similarity_threshold: 0.82).grouped_counts
  10. # # => { "ruby rails backend" => { label: "Ruby on Rails backend development", count: 2 } }
  11. #
  12. class Labels::DedupeService
  13. # @param labels [Array<String>]
  14. # @param similarity_threshold [Float] Jaccard similarity threshold for grouping
  15. # @param overlap_threshold [Float] overlap/min_size threshold (helps group "A + one word" variants)
  16. def initialize(labels, similarity_threshold: 0.85, overlap_threshold: 0.75)
  17. @labels = Array(labels)
  18. @similarity_threshold = similarity_threshold.to_f
  19. @overlap_threshold = overlap_threshold.to_f
  20. end
  21. # Returns labels de-duplicated into representative strings.
  22. #
  23. # @return [Array<String>]
  24. def run
  25. grouped_counts.values.map { |h| h[:label] }
  26. end
  27. # Returns grouped counts keyed by a normalized key.
  28. #
  29. # @return [Hash{String => Hash}] e.g. { "system design" => { label: "System Design", count: 2 } }
  30. def grouped_counts
  31. groups = []
  32. normalized_labels.each do |label|
  33. tokens = tokens_for(label)
  34. next if tokens.empty?
  35. best = best_group_for(groups, tokens)
  36. if best
  37. best[:count] += 1
  38. best[:candidates] << label
  39. best[:label] = pick_representative(best[:candidates])
  40. next
  41. end
  42. groups << {
  43. key: normalize_key(label),
  44. tokens: tokens,
  45. candidates: [label],
  46. label: label,
  47. count: 1
  48. }
  49. end
  50. groups.each_with_object({}) do |g, acc|
  51. acc[g[:key]] = { label: g[:label], count: g[:count] }
  52. end
  53. end
  54. private
  55. attr_reader :labels, :similarity_threshold, :overlap_threshold
  56. def normalized_labels
  57. labels.map { |l| l.to_s.strip }.reject(&:blank?)
  58. end
  59. # Produces a stable normalized key for a label (for exact-ish matching).
  60. #
  61. # @param label [String]
  62. # @return [String]
  63. def normalize_key(label)
  64. s = ActiveSupport::Inflector.transliterate(label.to_s)
  65. s = s.downcase
  66. s = s.tr("&", "and")
  67. s = s.gsub(/[^a-z0-9\s]/, " ")
  68. s = s.gsub(/\s+/, " ").strip
  69. s
  70. end
  71. STOPWORDS = %w[
  72. and or the a an to of for in on with across over into at from by
  73. ].freeze
  74. # @param label [String]
  75. # @return [Array<String>]
  76. def tokens_for(label)
  77. normalize_key(label).split(" ").reject { |t| t.blank? || STOPWORDS.include?(t) }.uniq
  78. end
  79. def best_group_for(groups, tokens)
  80. best = nil
  81. best_score = 0.0
  82. groups.each do |g|
  83. score = similarity(g[:tokens], tokens)
  84. overlap = overlap_ratio(g[:tokens], tokens)
  85. matches = score >= similarity_threshold || (overlap >= overlap_threshold && intersection_size(g[:tokens], tokens) >= 3)
  86. next unless matches
  87. if score > best_score
  88. best_score = score
  89. best = g
  90. end
  91. end
  92. best
  93. end
  94. def intersection_size(a, b)
  95. (a & b).size
  96. end
  97. def similarity(a, b)
  98. return 0.0 if a.empty? || b.empty?
  99. inter = intersection_size(a, b)
  100. union = (a | b).size
  101. union.positive? ? (inter.to_f / union) : 0.0
  102. end
  103. def overlap_ratio(a, b)
  104. return 0.0 if a.empty? || b.empty?
  105. inter = intersection_size(a, b)
  106. min_size = [a.size, b.size].min
  107. min_size.positive? ? (inter.to_f / min_size) : 0.0
  108. end
  109. def pick_representative(candidates)
  110. # Prefer the shortest non-trivial label (usually more readable).
  111. candidates
  112. .map { |c| c.to_s.strip }
  113. .reject(&:blank?)
  114. .min_by { |c| [c.length, c] }
  115. end
  116. end

app/services/llm_providers/anthropic_provider.rb

0.0% lines covered

100.0% branches covered

432 relevant lines. 0 lines covered and 432 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module LlmProviders
  3. # Anthropic Claude provider for LLM completions
  4. #
  5. # Uses the Anthropic Ruby SDK with streaming for efficient long-running requests.
  6. # Includes rate limiting via AnthropicRateLimiterService.
  7. #
  8. # Supports multimodal input:
  9. # - Images: JPEG, PNG, GIF, WebP
  10. # - Documents: PDF (native), DOCX (via text extraction)
  11. class AnthropicProvider < BaseProvider
  12. # Supported image MIME types
  13. SUPPORTED_IMAGE_TYPES = %w[
  14. image/jpeg
  15. image/png
  16. image/gif
  17. image/webp
  18. ].freeze
  19. # Natively supported document MIME types (sent as-is)
  20. NATIVE_DOCUMENT_TYPES = %w[
  21. application/pdf
  22. ].freeze
  23. # Document types requiring text extraction before sending
  24. TEXT_EXTRACTION_TYPES = %w[
  25. application/vnd.openxmlformats-officedocument.wordprocessingml.document
  26. ].freeze
  27. # All supported document MIME types
  28. SUPPORTED_DOCUMENT_TYPES = (NATIVE_DOCUMENT_TYPES + TEXT_EXTRACTION_TYPES).freeze
  29. # All supported media types
  30. SUPPORTED_MEDIA_TYPES = (SUPPORTED_IMAGE_TYPES + SUPPORTED_DOCUMENT_TYPES).freeze
  31. # Sends a prompt to Claude and returns the response
  32. #
  33. # @param prompt [String] The prompt text
  34. # @param options [Hash] Optional parameters
  35. # @option options [Integer] :max_tokens Maximum tokens in response
  36. # @option options [Float] :temperature Temperature setting
  37. # @option options [String] :system_message Optional system message
  38. # @option options [Array<Hash>] :media Array of media attachments (images, documents)
  39. # @return [Hash] Result with content and metadata
  40. def run(prompt, options = {})
  41. return rate_limit_error_response if prompt.present? && exceeds_rate_limit?(prompt)
  42. result, latency_ms = with_timing { call_api(prompt, options) }
  43. build_response(result, latency_ms)
  44. rescue => e
  45. handle_error(e)
  46. end
  47. # @return [Boolean] True - Anthropic Claude supports multimodal input
  48. def supports_media?
  49. true
  50. end
  51. # @return [Array<String>] Supported MIME types for media attachments
  52. def supported_media_types
  53. SUPPORTED_MEDIA_TYPES
  54. end
  55. protected
  56. def api_key
  57. Rails.application.credentials.dig(:anthropic, :api_key)
  58. end
  59. def default_model
  60. "claude-sonnet-4-20250514"
  61. end
  62. private
  63. # Checks rate limit and waits or returns error
  64. def exceeds_rate_limit?(prompt)
  65. estimated_tokens = estimate_tokens(prompt.to_s)
  66. unless rate_limiter.can_send_tokens?(estimated_tokens)
  67. wait_time = rate_limiter.wait_time_for_tokens(estimated_tokens)
  68. if wait_time > 0
  69. Rails.logger.warn("Anthropic rate limit: waiting #{wait_time}s")
  70. sleep(wait_time)
  71. return false
  72. end
  73. @rate_limit_tokens = estimated_tokens
  74. return true
  75. end
  76. false
  77. end
  78. def rate_limit_error_response
  79. error_response(
  80. error: "Request would exceed token rate limit",
  81. latency_ms: 0,
  82. error_type: "rate_limit",
  83. rate_limit: true
  84. )
  85. end
  86. # Makes the actual API call
  87. def call_api(prompt, options)
  88. @last_provider_request = build_params(prompt, options)
  89. @last_provider_endpoint =
  90. if Setting.helicone_enabled?
  91. Rails.application.credentials.dig(:helicone, :base_url)
  92. else
  93. nil
  94. end
  95. if Setting.helicone_enabled?
  96. client = Anthropic::Client.new(
  97. api_key: Rails.application.credentials.dig(:helicone, :api_key),
  98. base_url: Rails.application.credentials.dig(:helicone, :base_url)
  99. )
  100. else
  101. client = Anthropic::Client.new(api_key: api_key)
  102. end
  103. stream = client.messages.stream(**@last_provider_request)
  104. message = stream.accumulated_message
  105. message_hash = message.respond_to?(:to_h) ? message.to_h : message
  106. parsed = parse_message(message)
  107. # Use SDK-provided text accumulator as the most reliable source of assistant text.
  108. content = stream.accumulated_text.to_s
  109. content = parsed[:content] if content.blank?
  110. record_token_usage(message)
  111. message_id = message&.id.to_s
  112. message_id = "unknown" if message_id.blank?
  113. parsed = {
  114. raw_response: message_hash,
  115. content: content,
  116. tool_calls: parsed[:tool_calls],
  117. content_blocks: parsed[:content_blocks],
  118. message_id: message_id,
  119. provider_request: @last_provider_request,
  120. provider_response: message_hash.is_a?(Hash) ? message_hash : message_hash.to_s,
  121. provider_endpoint: @last_provider_endpoint,
  122. input_tokens: message&.usage&.input_tokens,
  123. output_tokens: message&.usage&.output_tokens
  124. }
  125. contract = Assistant::Contracts::ProviderResultContracts::Anthropic.call(parsed)
  126. unless contract.success?
  127. notify_error(RuntimeError.new("Anthropic provider contract failed"), operation: "call_api", error_type: "contract_failed", contract_errors: contract.errors.to_h)
  128. end
  129. parsed
  130. end
  131. def build_params(prompt, options)
  132. params = {
  133. model: model_name,
  134. max_tokens: options[:max_tokens] || max_tokens_config,
  135. temperature: options[:temperature] || temperature_config,
  136. messages: build_messages(prompt, options)
  137. }
  138. params[:system] = options[:system_message] if options[:system_message].present?
  139. params[:tools] = options[:tools] if options[:tools].present?
  140. params[:tool_choice] = options[:tool_choice] if options.key?(:tool_choice)
  141. params
  142. end
  143. def build_messages(prompt, options)
  144. # If caller provides pre-built messages, use them directly
  145. if options[:messages].present?
  146. messages = Array(options[:messages])
  147. # Attach media to the last user message if media is provided
  148. if options[:media].present?
  149. return inject_media_into_messages(messages, options[:media])
  150. end
  151. return messages
  152. end
  153. # Build simple user message with optional media
  154. content = build_content_with_media(prompt, options[:media])
  155. [ { role: "user", content: content } ]
  156. end
  157. # Builds content array with text and optional media blocks
  158. #
  159. # @param text [String] The text content
  160. # @param media [Array<Hash>, nil] Optional media attachments
  161. # @return [String, Array<Hash>] String if no media, Array of content blocks otherwise
  162. def build_content_with_media(text, media)
  163. return text.to_s if media.blank?
  164. content_blocks = []
  165. # Add media blocks first (images/documents)
  166. Array(media).each do |m|
  167. block = build_media_block(m)
  168. content_blocks << block if block
  169. end
  170. # Add text block
  171. content_blocks << { type: "text", text: text.to_s } if text.present?
  172. content_blocks
  173. end
  174. # Builds a single media content block for Anthropic's API
  175. #
  176. # @param media [Hash] Media attachment info
  177. # - :type [String] "image" or "document"
  178. # - :source_type [String] "base64" or "url"
  179. # - :media_type [String] MIME type
  180. # - :data [String] Base64 data (if source_type is "base64")
  181. # - :url [String] URL (if source_type is "url")
  182. # @return [Hash, nil] Content block for Anthropic API or nil if invalid
  183. def build_media_block(media)
  184. media = media.symbolize_keys
  185. media_type = media[:media_type].to_s
  186. return nil unless SUPPORTED_MEDIA_TYPES.include?(media_type)
  187. if media[:type].to_s == "document" || SUPPORTED_DOCUMENT_TYPES.include?(media_type)
  188. build_document_block(media)
  189. else
  190. build_image_block(media)
  191. end
  192. end
  193. # Builds an image content block
  194. def build_image_block(media)
  195. source = if media[:source_type].to_s == "url" && media[:url].present?
  196. { type: "url", url: media[:url] }
  197. elsif media[:data].present?
  198. { type: "base64", media_type: media[:media_type], data: media[:data] }
  199. end
  200. return nil unless source
  201. { type: "image", source: source }
  202. end
  203. # Builds a document content block (PDF native, DOCX via text extraction)
  204. def build_document_block(media)
  205. media_type = media[:media_type].to_s
  206. # DOCX requires text extraction since Claude doesn't natively support it
  207. if TEXT_EXTRACTION_TYPES.include?(media_type)
  208. return build_text_block_from_document(media)
  209. end
  210. # Native document support (PDF)
  211. source = if media[:source_type].to_s == "url" && media[:url].present?
  212. { type: "url", url: media[:url] }
  213. elsif media[:data].present?
  214. { type: "base64", media_type: media[:media_type], data: media[:data] }
  215. end
  216. return nil unless source
  217. {
  218. type: "document",
  219. source: source,
  220. cache_control: media[:cache_control] # Optional caching hint
  221. }.compact
  222. end
  223. # Extracts text from a DOCX file and returns it as a text block
  224. #
  225. # @param media [Hash] Media attachment with :data (base64) or :text (pre-extracted)
  226. # @return [Hash, nil] Text content block or nil
  227. def build_text_block_from_document(media)
  228. # If text was pre-extracted, use it directly
  229. if media[:extracted_text].present?
  230. return { type: "text", text: format_document_text(media[:extracted_text], media[:filename]) }
  231. end
  232. # If we have base64 data, try to extract text from DOCX
  233. if media[:data].present? && media[:media_type] == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  234. extracted = extract_text_from_docx(media[:data])
  235. if extracted.present?
  236. return { type: "text", text: format_document_text(extracted, media[:filename]) }
  237. end
  238. end
  239. nil
  240. end
  241. # Extracts text from a base64-encoded DOCX file
  242. #
  243. # @param base64_data [String] Base64-encoded DOCX content
  244. # @return [String, nil] Extracted text or nil if extraction fails
  245. def extract_text_from_docx(base64_data)
  246. require "docx"
  247. require "base64"
  248. require "tempfile"
  249. Tempfile.create([ "document", ".docx" ]) do |temp|
  250. temp.binmode
  251. temp.write(Base64.decode64(base64_data))
  252. temp.rewind
  253. doc = Docx::Document.open(temp.path)
  254. paragraphs = doc.paragraphs.map(&:text).reject(&:blank?)
  255. paragraphs.join("\n\n")
  256. end
  257. rescue LoadError => e
  258. Rails.logger.warn("DOCX extraction unavailable: #{e.message}. Install 'docx' gem for DOCX support.")
  259. nil
  260. rescue => e
  261. Rails.logger.error("Failed to extract text from DOCX: #{e.message}")
  262. nil
  263. end
  264. # Formats extracted document text with context
  265. def format_document_text(text, filename = nil)
  266. header = filename.present? ? "--- Document: #{filename} ---\n\n" : "--- Document Content ---\n\n"
  267. "#{header}#{text}\n\n--- End of Document ---"
  268. end
  269. # Injects media into the last user message in a messages array
  270. #
  271. # @param messages [Array<Hash>] Existing messages
  272. # @param media [Array<Hash>] Media to inject
  273. # @return [Array<Hash>] Messages with media injected
  274. def inject_media_into_messages(messages, media)
  275. return messages if media.blank?
  276. # Find the last user message
  277. last_user_idx = messages.rindex { |m| m[:role] == "user" || m["role"] == "user" }
  278. return messages unless last_user_idx
  279. messages = messages.deep_dup
  280. last_msg = messages[last_user_idx]
  281. existing_content = last_msg[:content] || last_msg["content"]
  282. # Convert string content to content blocks
  283. if existing_content.is_a?(String)
  284. new_content = build_content_with_media(existing_content, media)
  285. elsif existing_content.is_a?(Array)
  286. # Already an array of content blocks, prepend media
  287. media_blocks = Array(media).filter_map { |m| build_media_block(m) }
  288. new_content = media_blocks + existing_content
  289. else
  290. new_content = build_content_with_media("", media)
  291. end
  292. messages[last_user_idx] = last_msg.merge(content: new_content)
  293. messages
  294. end
  295. def parse_message(message)
  296. message_hash = message.respond_to?(:to_h) ? message.to_h : message
  297. blocks = message_hash.is_a?(Hash) ? (message_hash["content"] || message_hash[:content]) : message&.content
  298. return { content: "", tool_calls: [] } unless blocks.is_a?(Array)
  299. text_parts = []
  300. tool_calls = []
  301. content_blocks = []
  302. blocks.each do |b|
  303. h =
  304. if b.is_a?(Hash)
  305. b
  306. elsif b.respond_to?(:to_h)
  307. b.to_h
  308. else
  309. # Best-effort for SDK objects
  310. {
  311. type: (b.respond_to?(:type) ? b.type : nil),
  312. text: (b.respond_to?(:text) ? b.text : nil),
  313. id: (b.respond_to?(:id) ? b.id : nil),
  314. name: (b.respond_to?(:name) ? b.name : nil),
  315. input: (b.respond_to?(:input) ? b.input : nil)
  316. }.compact
  317. end
  318. next unless h.is_a?(Hash)
  319. type = (h["type"] || h[:type]).to_s
  320. case type
  321. when "text"
  322. text_parts << (h["text"] || h[:text]).to_s
  323. when "output_text"
  324. text_parts << (h["text"] || h[:text]).to_s
  325. when "tool_use"
  326. raw_input = h["input"] || h[:input] || {}
  327. parsed_input =
  328. if raw_input.is_a?(String)
  329. begin
  330. JSON.parse(raw_input)
  331. rescue JSON::ParserError
  332. Rails.logger.warn("[AnthropicProvider] Failed to parse tool_use.input JSON; defaulting to {} input=#{raw_input.to_s[0, 200].inspect}")
  333. {}
  334. end
  335. else
  336. raw_input
  337. end
  338. # Ensure stored content blocks match Anthropic's expected shape.
  339. # Anthropic requires tool_use.input to be an object (dictionary). Some SDK versions
  340. # surface it as a JSON string; normalize it here so future follow-ups can safely
  341. # replay provider_content_blocks without 400s.
  342. parsed_input = {} unless parsed_input.is_a?(Hash)
  343. h["input"] = parsed_input if h.key?("input") || h.key?("input".to_sym)
  344. h[:input] = parsed_input if h.key?(:input)
  345. tool_calls << {
  346. id: h["id"] || h[:id],
  347. tool_key: h["name"] || h[:name],
  348. args: parsed_input
  349. }
  350. else
  351. # Best-effort: if this block contains a text payload, capture it.
  352. text = h["text"] || h[:text]
  353. text_parts << text.to_s if text.is_a?(String) && text.present?
  354. end
  355. # Store a sanitized version for safe replay during follow-ups (no SDK internals like _json_buf).
  356. content_blocks << sanitize_anthropic_content_block(h)
  357. end
  358. { content: text_parts.join, tool_calls: tool_calls, content_blocks: content_blocks }
  359. end
  360. # Anthropic is strict about content blocks: tool_use blocks cannot include extra keys.
  361. # Keep only the allowed/documented fields so replays don't 400.
  362. #
  363. # @param h [Hash]
  364. # @return [Hash]
  365. def sanitize_anthropic_content_block(h)
  366. type = (h["type"] || h[:type]).to_s
  367. case type
  368. when "tool_use"
  369. input = h["input"] || h[:input]
  370. input = {} unless input.is_a?(Hash)
  371. {
  372. "type" => "tool_use",
  373. "id" => (h["id"] || h[:id]).to_s.presence,
  374. "name" => (h["name"] || h[:name]).to_s.presence,
  375. "input" => input
  376. }.compact
  377. when "text", "output_text"
  378. { "type" => "text", "text" => (h["text"] || h[:text]).to_s }
  379. else
  380. text = h["text"] || h[:text]
  381. out = { "type" => type.presence || "text" }
  382. out["text"] = text.to_s if text.is_a?(String) && text.present?
  383. out
  384. end
  385. rescue StandardError
  386. { "type" => "text", "text" => "" }
  387. end
  388. def build_response(result, latency_ms)
  389. success_response(
  390. content: result[:content],
  391. latency_ms: latency_ms,
  392. input_tokens: result[:input_tokens],
  393. output_tokens: result[:output_tokens],
  394. provider_request: result[:provider_request],
  395. provider_response: result[:provider_response],
  396. provider_endpoint: result[:provider_endpoint]
  397. ).merge(
  398. tool_calls: result[:tool_calls],
  399. content_blocks: result[:content_blocks],
  400. message_id: result[:message_id]
  401. )
  402. end
  403. def handle_error(exception)
  404. latency_ms = 0 # Error occurred, timing not meaningful
  405. if rate_limit_error?(exception)
  406. handle_rate_limit_error(exception, latency_ms)
  407. else
  408. handle_general_error(exception, latency_ms)
  409. end
  410. end
  411. def handle_rate_limit_error(exception, latency_ms)
  412. Rails.logger.warn("Anthropic rate limit exceeded: #{exception.message}")
  413. retry_after = extract_retry_after(exception)
  414. http_status = extract_http_status(exception)
  415. error_response_hash = extract_error_response_hash(exception)
  416. notify_error(exception, operation: "run", error_type: "rate_limit_exceeded", retry_after: retry_after, http_status: http_status)
  417. error_response(
  418. error: "Rate limit exceeded: #{exception.message}",
  419. latency_ms: latency_ms,
  420. error_type: "rate_limit",
  421. rate_limit: true,
  422. retry_after: retry_after,
  423. provider_request: @last_provider_request,
  424. provider_error_response: error_response_hash,
  425. http_status: http_status,
  426. response_headers: error_response_hash&.dig(:headers),
  427. provider_endpoint: @last_provider_endpoint
  428. )
  429. end
  430. def handle_general_error(exception, latency_ms)
  431. Rails.logger.error("Anthropic request failed: #{exception.message}")
  432. http_status = extract_http_status(exception)
  433. error_response_hash = extract_error_response_hash(exception)
  434. notify_error(exception, operation: "run", error_type: "request_failed", http_status: http_status)
  435. error_response(
  436. error: exception.message,
  437. latency_ms: latency_ms,
  438. error_type: exception.class.name,
  439. provider_request: @last_provider_request,
  440. provider_error_response: error_response_hash,
  441. http_status: http_status,
  442. response_headers: error_response_hash&.dig(:headers),
  443. provider_endpoint: @last_provider_endpoint
  444. )
  445. end
  446. # Rate limiting helpers
  447. def rate_limiter
  448. @rate_limiter ||= Scraping::AnthropicRateLimiterService.new
  449. end
  450. def record_token_usage(message)
  451. input_tokens = message&.usage&.input_tokens
  452. rate_limiter.record_tokens_used(input_tokens) if input_tokens
  453. end
  454. def estimate_tokens(text)
  455. (text.length.to_f / 3.0).ceil
  456. end
  457. def rate_limit_error?(error)
  458. message = error.message.to_s.downcase
  459. return true if message.include?("rate_limit") || message.include?("rate limit") || message.include?("429")
  460. return true if error.respond_to?(:status) && error.status == 429
  461. check_response_for_rate_limit(error)
  462. end
  463. def check_response_for_rate_limit(error)
  464. return false unless error.respond_to?(:response)
  465. response = error.response
  466. return false unless response.is_a?(Hash)
  467. return true if response[:status] == 429 || response["status"] == 429
  468. error_type = response.dig(:body, :error, :type) || response.dig("body", "error", "type")
  469. error_type&.downcase&.include?("rate_limit") || false
  470. end
  471. def extract_retry_after(error)
  472. return nil unless error.respond_to?(:response)
  473. headers = error.response&.dig(:headers) || error.response&.dig("headers") || {}
  474. retry_after = headers["retry-after"] || headers[:retry_after] || headers["Retry-After"]
  475. retry_after&.to_i
  476. rescue
  477. nil
  478. end
  479. def extract_http_status(exception)
  480. return nil unless exception.respond_to?(:response)
  481. response = exception.response
  482. return response[:status] || response["status"] if response.is_a?(Hash)
  483. nil
  484. rescue StandardError
  485. nil
  486. end
  487. def extract_response_body(exception)
  488. return nil unless exception.respond_to?(:response)
  489. response = exception.response
  490. return response[:body] || response["body"] if response.is_a?(Hash)
  491. nil
  492. rescue StandardError
  493. nil
  494. end
  495. def extract_response_headers(exception)
  496. return nil unless exception.respond_to?(:response)
  497. response = exception.response
  498. return response[:headers] || response["headers"] if response.is_a?(Hash)
  499. nil
  500. rescue StandardError
  501. nil
  502. end
  503. def extract_error_response_hash(exception)
  504. http_status = extract_http_status(exception)
  505. body = extract_response_body(exception)
  506. headers = extract_response_headers(exception)
  507. return nil if http_status.blank? && body.blank? && headers.blank?
  508. {
  509. status: http_status,
  510. headers: headers,
  511. body: body
  512. }.compact
  513. end
  514. end
  515. end

app/services/llm_providers/base_provider.rb

0.0% lines covered

100.0% branches covered

113 relevant lines. 0 lines covered and 113 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module LlmProviders
  3. # Base provider class for LLM integrations
  4. #
  5. # Providers are responsible for:
  6. # - Making API calls to LLM services
  7. # - Error handling and rate limiting
  8. # - Instrumentation (latency, token usage)
  9. #
  10. # Providers are NOT responsible for:
  11. # - Building prompts (done by services)
  12. # - Parsing responses (done by services)
  13. #
  14. # @abstract Subclass and override {#run} to implement
  15. class BaseProvider
  16. # Logging context attributes (set by caller for observability)
  17. attr_accessor :scraping_attempt, :job_listing
  18. # Sends a prompt to the LLM and returns the response
  19. #
  20. # @param prompt [String] The prompt text to send
  21. # @param options [Hash] Optional parameters
  22. # @option options [Integer] :max_tokens Maximum tokens in response
  23. # @option options [Float] :temperature Temperature setting (0-1)
  24. # @option options [String] :system_message Optional system message
  25. # @option options [Array<Hash>] :media Array of media attachments for multimodal input
  26. # Each media hash should contain:
  27. # - :type [String] Media type: "image" or "document"
  28. # - :source_type [String] Source type: "base64" or "url"
  29. # - :media_type [String] MIME type (e.g., "image/png", "application/pdf")
  30. # - :data [String] Base64-encoded data (if source_type is "base64")
  31. # - :url [String] URL to media (if source_type is "url")
  32. # @return [Hash] Result hash with:
  33. # - :content [String] The LLM response text
  34. # - :provider [String] Provider name
  35. # - :model [String] Model used
  36. # - :input_tokens [Integer] Input token count
  37. # - :output_tokens [Integer] Output token count
  38. # - :latency_ms [Integer] Request latency in milliseconds
  39. # - :error [String, nil] Error message if failed
  40. # - :rate_limit [Boolean] True if rate limited
  41. # - :provider_request [Hash, nil] Provider-native request payload (best-effort, sanitized)
  42. # - :provider_response [Hash, String, nil] Provider-native raw response payload (best-effort, sanitized)
  43. # - :provider_error_response [Hash, String, nil] Provider-native raw error response payload (best-effort, sanitized)
  44. # - :http_status [Integer, nil] HTTP status code if available
  45. # - :response_headers [Hash, nil] HTTP response headers if available
  46. # - :provider_endpoint [String, nil] Provider endpoint/base URL if available
  47. # @raise [NotImplementedError] Must be implemented by subclass
  48. def run(prompt, options = {})
  49. raise NotImplementedError, "#{self.class} must implement #run"
  50. end
  51. # Checks if this provider supports multimodal input (images, documents)
  52. #
  53. # @return [Boolean] True if provider supports media attachments
  54. def supports_media?
  55. false
  56. end
  57. # Returns supported media types for this provider
  58. #
  59. # @return [Array<String>] Array of supported MIME types
  60. def supported_media_types
  61. []
  62. end
  63. # Checks if the provider is available and configured
  64. #
  65. # @return [Boolean] True if provider can be used
  66. def available?
  67. api_key.present? && enabled?
  68. end
  69. # Returns the provider name (e.g., "anthropic", "openai")
  70. #
  71. # @return [String] Provider name
  72. def provider_name
  73. self.class.name.demodulize.gsub("Provider", "").downcase
  74. end
  75. # Returns the model name being used
  76. #
  77. # @return [String] Model name
  78. def model_name
  79. db_config&.llm_model || config["model"] || default_model
  80. end
  81. protected
  82. # Returns the API key for this provider
  83. #
  84. # @return [String, nil] API key or nil if not configured
  85. # @raise [NotImplementedError] Must be implemented by subclass
  86. def api_key
  87. raise NotImplementedError, "#{self.class} must implement #api_key"
  88. end
  89. # Returns the default model for this provider
  90. #
  91. # @return [String] Default model name
  92. def default_model
  93. "unknown"
  94. end
  95. # Returns the database configuration for this provider
  96. #
  97. # @return [LlmProviderConfig, nil] Provider configuration or nil
  98. def db_config
  99. @db_config ||= ::LlmProviderConfig.by_provider_type(provider_name).enabled.first
  100. end
  101. # Returns the configuration hash for this provider
  102. #
  103. # @return [Hash] Provider configuration
  104. def config
  105. @config ||= db_config&.to_config || {}
  106. end
  107. # Checks if provider is enabled in configuration
  108. #
  109. # @return [Boolean] True if enabled
  110. def enabled?
  111. db_config&.enabled? || false
  112. end
  113. # Returns max tokens from config or default
  114. #
  115. # @param default [Integer] Default value
  116. # @return [Integer] Max tokens
  117. def max_tokens_config(default: 4096)
  118. db_config&.max_tokens || default
  119. end
  120. # Returns temperature from config or default
  121. #
  122. # @param default [Float] Default value
  123. # @return [Float] Temperature
  124. def temperature_config(default: 0)
  125. db_config&.temperature || default
  126. end
  127. # Measures execution time and returns latency in ms
  128. #
  129. # @yield Block to measure
  130. # @return [Array] [result, latency_ms]
  131. def with_timing
  132. start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  133. result = yield
  134. end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  135. latency_ms = ((end_time - start_time) * 1000).round
  136. [ result, latency_ms ]
  137. end
  138. # Builds a success response hash
  139. #
  140. # @param content [String] Response content
  141. # @param latency_ms [Integer] Latency in milliseconds
  142. # @param input_tokens [Integer, nil] Input token count
  143. # @param output_tokens [Integer, nil] Output token count
  144. # @return [Hash] Success response
  145. def success_response(
  146. content:,
  147. latency_ms:,
  148. input_tokens: nil,
  149. output_tokens: nil,
  150. provider_request: nil,
  151. provider_response: nil,
  152. http_status: nil,
  153. response_headers: nil,
  154. provider_endpoint: nil
  155. )
  156. response = {
  157. content: content,
  158. provider: provider_name,
  159. model: model_name,
  160. input_tokens: input_tokens,
  161. output_tokens: output_tokens,
  162. latency_ms: latency_ms
  163. }
  164. response[:provider_request] = provider_request if provider_request.present?
  165. response[:provider_response] = provider_response if provider_response.present?
  166. response[:http_status] = http_status if http_status.present?
  167. response[:response_headers] = response_headers if response_headers.present?
  168. response[:provider_endpoint] = provider_endpoint if provider_endpoint.present?
  169. response
  170. end
  171. # Builds an error response hash
  172. #
  173. # @param error [String] Error message
  174. # @param latency_ms [Integer] Latency in milliseconds
  175. # @param error_type [String] Error classification
  176. # @param rate_limit [Boolean] Whether this is a rate limit error
  177. # @param retry_after [Integer, nil] Seconds to wait before retry
  178. # @return [Hash] Error response
  179. def error_response(
  180. error:,
  181. latency_ms:,
  182. error_type: nil,
  183. rate_limit: false,
  184. retry_after: nil,
  185. provider_request: nil,
  186. provider_error_response: nil,
  187. http_status: nil,
  188. response_headers: nil,
  189. provider_endpoint: nil
  190. )
  191. response = {
  192. content: nil,
  193. error: error,
  194. provider: provider_name,
  195. model: model_name,
  196. error_type: error_type,
  197. latency_ms: latency_ms
  198. }
  199. response[:rate_limit] = true if rate_limit
  200. response[:retry_after] = retry_after if retry_after
  201. response[:provider_request] = provider_request if provider_request.present?
  202. response[:provider_error_response] = provider_error_response if provider_error_response.present?
  203. response[:http_status] = http_status if http_status.present?
  204. response[:response_headers] = response_headers if response_headers.present?
  205. response[:provider_endpoint] = provider_endpoint if provider_endpoint.present?
  206. response
  207. end
  208. # Notifies about an AI error
  209. #
  210. # @param exception [Exception] The exception
  211. # @param context [Hash] Additional context
  212. def notify_error(exception, context = {})
  213. ExceptionNotifier.notify_ai_error(exception, {
  214. provider_name: provider_name,
  215. model_identifier: model_name
  216. }.merge(context))
  217. end
  218. end
  219. end

app/services/llm_providers/ollama_provider.rb

0.0% lines covered

100.0% branches covered

109 relevant lines. 0 lines covered and 109 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module LlmProviders
  3. # Ollama provider for self-hosted LLM completions
  4. #
  5. # Uses the Ollama REST API for local model inference.
  6. # Does not require an API key - connects to local Ollama server.
  7. class OllamaProvider < BaseProvider
  8. REQUEST_TIMEOUT = 120 # Longer timeout for local inference
  9. # Sends a prompt to Ollama and returns the response
  10. #
  11. # @param prompt [String] The prompt text
  12. # @param options [Hash] Optional parameters (temperature not supported by Ollama)
  13. # @return [Hash] Result with content and metadata
  14. def run(prompt, options = {})
  15. result, latency_ms = with_timing { call_api(prompt, options) }
  16. build_response(result, latency_ms)
  17. rescue => e
  18. handle_error(e)
  19. end
  20. # Ollama doesn't require an API key
  21. def available?
  22. enabled? && ollama_endpoint.present?
  23. end
  24. protected
  25. def api_key
  26. "local" # Dummy value for availability check
  27. end
  28. def default_model
  29. "llama3"
  30. end
  31. private
  32. def call_api(prompt, options)
  33. request_body = build_request_body(prompt, options)
  34. @last_provider_request = request_body
  35. response = HTTParty.post(
  36. "#{ollama_endpoint}/api/generate",
  37. headers: { "Content-Type" => "application/json" },
  38. body: request_body.to_json,
  39. timeout: REQUEST_TIMEOUT
  40. )
  41. parse_response(response, provider_request: request_body)
  42. end
  43. def build_request_body(prompt, options)
  44. body = {
  45. model: model_name,
  46. prompt: prompt,
  47. stream: false
  48. }
  49. # Only request JSON format if caller expects it
  50. body[:format] = "json" if options[:json_format]
  51. body
  52. end
  53. def parse_response(response, provider_request:)
  54. unless response.success?
  55. return {
  56. error: "Ollama request failed: #{response.code}",
  57. provider_request: provider_request,
  58. provider_error_response: {
  59. status: response.code,
  60. headers: (response.respond_to?(:headers) ? response.headers.to_h : nil),
  61. body: response.body
  62. }.compact,
  63. http_status: response.code,
  64. response_headers: (response.respond_to?(:headers) ? response.headers.to_h : nil),
  65. provider_endpoint: ollama_endpoint
  66. }
  67. end
  68. parsed = response.parsed_response
  69. {
  70. content: parsed["response"],
  71. input_tokens: parsed["prompt_eval_count"],
  72. output_tokens: parsed["eval_count"],
  73. provider_request: provider_request,
  74. provider_response: parsed,
  75. http_status: response.code,
  76. response_headers: (response.respond_to?(:headers) ? response.headers.to_h : nil),
  77. provider_endpoint: ollama_endpoint
  78. }
  79. end
  80. def build_response(result, latency_ms)
  81. if result[:error]
  82. error_response(
  83. error: result[:error],
  84. latency_ms: latency_ms,
  85. error_type: "http_error",
  86. provider_request: result[:provider_request],
  87. provider_error_response: result[:provider_error_response],
  88. http_status: result[:http_status],
  89. response_headers: result[:response_headers],
  90. provider_endpoint: result[:provider_endpoint]
  91. )
  92. else
  93. success_response(
  94. content: result[:content],
  95. latency_ms: latency_ms,
  96. input_tokens: result[:input_tokens],
  97. output_tokens: result[:output_tokens],
  98. provider_request: result[:provider_request],
  99. provider_response: result[:provider_response],
  100. http_status: result[:http_status],
  101. response_headers: result[:response_headers],
  102. provider_endpoint: result[:provider_endpoint]
  103. )
  104. end
  105. end
  106. def handle_error(exception)
  107. Rails.logger.error("Ollama request failed: #{exception.message}")
  108. notify_error(exception, operation: "run", error_type: "request_failed", endpoint: ollama_endpoint)
  109. error_response(
  110. error: exception.message,
  111. latency_ms: 0,
  112. error_type: exception.class.name,
  113. provider_request: @last_provider_request,
  114. provider_endpoint: ollama_endpoint
  115. )
  116. end
  117. def ollama_endpoint
  118. db_config&.api_endpoint || "http://localhost:11434"
  119. end
  120. end
  121. end

app/services/llm_providers/openai_provider.rb

0.0% lines covered

100.0% branches covered

317 relevant lines. 0 lines covered and 317 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module LlmProviders
  3. # OpenAI provider for LLM completions
  4. #
  5. # Uses the OpenAI Responses API for better reliability and structured outputs.
  6. # Reference: https://platform.openai.com/docs/api-reference/responses
  7. #
  8. # Supports multimodal input:
  9. # - Images: JPEG, PNG, GIF, WebP
  10. # - Documents: PDF, DOCX (via file input)
  11. class OpenaiProvider < BaseProvider
  12. # Request timeout in seconds for API calls
  13. REQUEST_TIMEOUT = 120
  14. # Supported image MIME types
  15. SUPPORTED_IMAGE_TYPES = %w[
  16. image/jpeg
  17. image/png
  18. image/gif
  19. image/webp
  20. ].freeze
  21. # Supported document MIME types
  22. SUPPORTED_DOCUMENT_TYPES = %w[
  23. application/pdf
  24. application/vnd.openxmlformats-officedocument.wordprocessingml.document
  25. ].freeze
  26. # All supported media types
  27. SUPPORTED_MEDIA_TYPES = (SUPPORTED_IMAGE_TYPES + SUPPORTED_DOCUMENT_TYPES).freeze
  28. # Sends a prompt to OpenAI and returns the response
  29. #
  30. # @param prompt [String] The prompt text
  31. # @param options [Hash] Optional parameters
  32. # @option options [Integer] :max_tokens Maximum tokens in response
  33. # @option options [Float] :temperature Temperature setting
  34. # @option options [String] :system_message Optional system message
  35. # @option options [Array<Hash>] :media Array of media attachments (images, documents)
  36. # @return [Hash] Result with content and metadata
  37. def run(prompt, options = {})
  38. result, latency_ms = with_timing { call_api(prompt, options) }
  39. build_response(result, latency_ms)
  40. rescue JSON::ParserError => e
  41. handle_json_error(e)
  42. rescue => e
  43. handle_error(e)
  44. end
  45. # @return [Boolean] True - OpenAI GPT-4o supports multimodal input
  46. def supports_media?
  47. true
  48. end
  49. # @return [Array<String>] Supported MIME types for media attachments
  50. def supported_media_types
  51. SUPPORTED_MEDIA_TYPES
  52. end
  53. protected
  54. def api_key
  55. Rails.application.credentials.dig(:openai, :api_key)
  56. end
  57. def default_model
  58. "gpt-4o-mini"
  59. end
  60. private
  61. def call_api(prompt, options)
  62. @last_provider_request = build_params(prompt, options)
  63. @last_provider_endpoint =
  64. if Setting.helicone_enabled?
  65. Rails.application.credentials.dig(:helicone, :base_url)
  66. else
  67. nil
  68. end
  69. if Setting.helicone_enabled?
  70. client = OpenAI::Client.new(
  71. access_token: Rails.application.credentials.dig(:helicone, :api_key),
  72. uri_base: Rails.application.credentials.dig(:helicone, :base_url),
  73. request_timeout: REQUEST_TIMEOUT
  74. )
  75. else
  76. client = OpenAI::Client.new(
  77. access_token: api_key,
  78. request_timeout: REQUEST_TIMEOUT
  79. )
  80. end
  81. response = client.responses.create(parameters: @last_provider_request)
  82. parse_response(response, provider_request: @last_provider_request, provider_endpoint: @last_provider_endpoint)
  83. end
  84. def build_params(prompt, options)
  85. input = build_input(prompt, options)
  86. params = {
  87. model: model_name,
  88. input: input,
  89. temperature: options[:temperature] || temperature_config,
  90. max_output_tokens: options[:max_tokens] || max_tokens_config(default: 16384)
  91. }
  92. params[:previous_response_id] = options[:previous_response_id] if options[:previous_response_id].present?
  93. if options[:tools].present?
  94. params[:tools] = options[:tools]
  95. params[:tool_choice] = options[:tool_choice] if options.key?(:tool_choice)
  96. end
  97. params
  98. end
  99. def build_input(prompt, options)
  100. # Supports both legacy prompt string and structured input/messages for tool calling.
  101. #
  102. # Legacy: prompt + optional system_message -> [{role, content}, ...]
  103. # Tool calling: options[:messages] already formatted as [{role, content}, ...]
  104. input = []
  105. if options[:messages].present?
  106. messages = Array(options[:messages])
  107. # Inject media into the last user message if provided
  108. if options[:media].present?
  109. messages = inject_media_into_messages(messages, options[:media])
  110. end
  111. input.concat(messages)
  112. else
  113. system_message = options[:system_message]
  114. input << { role: "system", content: system_message } if system_message.present?
  115. # For continuation requests (previous_response_id + tool_outputs), we may not need to add a user message.
  116. # Never send `content: nil` (OpenAI rejects it with invalid_type).
  117. prompt_text = prompt.to_s
  118. if prompt_text.present?
  119. content = build_content_with_media(prompt_text, options[:media])
  120. input << { role: "user", content: content }
  121. end
  122. end
  123. Array(options[:tool_outputs]).each do |tool_output|
  124. call_id = tool_output[:call_id] || tool_output["call_id"] || tool_output[:tool_call_id] || tool_output["tool_call_id"]
  125. output = tool_output[:output] || tool_output["output"]
  126. next if call_id.blank?
  127. # Responses API commonly accepts function_call_output items; keep it provider-specific here.
  128. input << {
  129. type: "function_call_output",
  130. call_id: call_id,
  131. output: output.is_a?(String) ? output : output.to_json
  132. }
  133. end
  134. input
  135. end
  136. # Builds content array with text and optional media blocks for OpenAI
  137. #
  138. # @param text [String] The text content
  139. # @param media [Array<Hash>, nil] Optional media attachments
  140. # @return [String, Array<Hash>] String if no media, Array of content blocks otherwise
  141. def build_content_with_media(text, media)
  142. return text.to_s if media.blank?
  143. content_blocks = []
  144. # Add text block first
  145. content_blocks << { type: "text", text: text.to_s } if text.present?
  146. # Add media blocks
  147. Array(media).each do |m|
  148. block = build_media_block(m)
  149. content_blocks << block if block
  150. end
  151. content_blocks.size == 1 && content_blocks.first[:type] == "text" ? text.to_s : content_blocks
  152. end
  153. # Builds a single media content block for OpenAI's API
  154. #
  155. # OpenAI uses different formats:
  156. # - Images: { type: "image_url", image_url: { url: "data:image/jpeg;base64,..." } }
  157. # - Files (Responses API): { type: "input_file", file_id: "..." } or inline via base64
  158. #
  159. # @param media [Hash] Media attachment info
  160. # - :type [String] "image" or "document"
  161. # - :source_type [String] "base64" or "url"
  162. # - :media_type [String] MIME type
  163. # - :data [String] Base64 data (if source_type is "base64")
  164. # - :url [String] URL (if source_type is "url")
  165. # - :file_id [String] OpenAI file ID (if already uploaded)
  166. # @return [Hash, nil] Content block for OpenAI API or nil if invalid
  167. def build_media_block(media)
  168. media = media.symbolize_keys
  169. media_type = media[:media_type].to_s
  170. return nil unless SUPPORTED_MEDIA_TYPES.include?(media_type)
  171. if SUPPORTED_IMAGE_TYPES.include?(media_type)
  172. build_image_block(media)
  173. else
  174. build_file_block(media)
  175. end
  176. end
  177. # Builds an image content block for OpenAI
  178. # Format: { type: "image_url", image_url: { url: "..." } }
  179. def build_image_block(media)
  180. url = if media[:source_type].to_s == "url" && media[:url].present?
  181. media[:url]
  182. elsif media[:data].present?
  183. "data:#{media[:media_type]};base64,#{media[:data]}"
  184. end
  185. return nil unless url
  186. {
  187. type: "image_url",
  188. image_url: { url: url, detail: media[:detail] || "auto" }
  189. }
  190. end
  191. # Builds a file content block for OpenAI (PDF, DOCX)
  192. # For Responses API, uses input_file with file_id or inline base64
  193. def build_file_block(media)
  194. # If we have a file_id (already uploaded to OpenAI), use it
  195. if media[:file_id].present?
  196. return {
  197. type: "input_file",
  198. file_id: media[:file_id]
  199. }
  200. end
  201. # For base64 data, we can use inline file content
  202. # Note: OpenAI Responses API supports inline file via base64
  203. if media[:data].present?
  204. return {
  205. type: "input_file",
  206. filename: media[:filename] || default_filename_for(media[:media_type]),
  207. file_data: "data:#{media[:media_type]};base64,#{media[:data]}"
  208. }
  209. end
  210. # URL-based files need to be downloaded and uploaded to OpenAI first
  211. # For now, we don't support URL-based files directly
  212. nil
  213. end
  214. # Returns a default filename based on media type
  215. def default_filename_for(media_type)
  216. case media_type
  217. when "application/pdf"
  218. "document.pdf"
  219. when "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  220. "document.docx"
  221. else
  222. "file"
  223. end
  224. end
  225. # Injects media into the last user message in a messages array
  226. #
  227. # @param messages [Array<Hash>] Existing messages
  228. # @param media [Array<Hash>] Media to inject
  229. # @return [Array<Hash>] Messages with media injected
  230. def inject_media_into_messages(messages, media)
  231. return messages if media.blank?
  232. # Find the last user message
  233. last_user_idx = messages.rindex { |m| m[:role] == "user" || m["role"] == "user" }
  234. return messages unless last_user_idx
  235. messages = messages.deep_dup
  236. last_msg = messages[last_user_idx]
  237. existing_content = last_msg[:content] || last_msg["content"]
  238. # Convert string content to content blocks with media
  239. if existing_content.is_a?(String)
  240. new_content = build_content_with_media(existing_content, media)
  241. elsif existing_content.is_a?(Array)
  242. # Already an array of content blocks, append media
  243. media_blocks = Array(media).filter_map { |m| build_media_block(m) }
  244. new_content = existing_content + media_blocks
  245. else
  246. new_content = build_content_with_media("", media)
  247. end
  248. messages[last_user_idx] = last_msg.merge(content: new_content)
  249. messages
  250. end
  251. def parse_response(response, provider_request:, provider_endpoint:)
  252. response_data = response.is_a?(Hash) ? response : response.to_h
  253. content = extract_content(response_data)
  254. tool_calls = extract_tool_calls(response_data)
  255. response_id = response_data["id"] || response_data[:id]
  256. usage = response_data["usage"] || {}
  257. response_id = response_data["id"] || response_data[:id]
  258. response_id = response_id.to_s
  259. response_id = "unknown" if response_id.blank?
  260. parsed = {
  261. raw_response: response_data,
  262. content: content,
  263. tool_calls: tool_calls,
  264. response_id: response_id,
  265. provider_request: provider_request,
  266. provider_response: response_data,
  267. provider_endpoint: provider_endpoint,
  268. input_tokens: usage["input_tokens"],
  269. output_tokens: usage["output_tokens"]
  270. }
  271. contract = Assistant::Contracts::ProviderResultContracts::Openai.call(parsed)
  272. unless contract.success?
  273. notify_error(RuntimeError.new("OpenAI provider contract failed"), operation: "parse_response", error_type: "contract_failed", contract_errors: contract.errors.to_h)
  274. end
  275. parsed
  276. end
  277. def extract_content(response_data)
  278. # Responses API structure: output -> [{ type: "message", content: [{ type: "output_text", text: "..." }] }]
  279. output = response_data["output"]
  280. return "" unless output.is_a?(Array)
  281. message = output.find { |o| o["type"] == "message" }
  282. return "" unless message
  283. content_blocks = message["content"]
  284. return "" unless content_blocks.is_a?(Array)
  285. text_block = content_blocks.find { |c| c["type"] == "output_text" }
  286. text_block&.dig("text") || ""
  287. end
  288. def extract_tool_calls(response_data)
  289. output = response_data["output"]
  290. return [] unless output.is_a?(Array)
  291. calls = output.select { |o| o.is_a?(Hash) && o["type"].to_s.include?("function_call") }
  292. calls.map do |call|
  293. {
  294. id: call["call_id"] || call["id"],
  295. tool_key: call["name"] || call.dig("function", "name"),
  296. args: parse_tool_args(call["arguments"] || call.dig("function", "arguments"))
  297. }
  298. end.select { |c| c[:tool_key].present? }
  299. end
  300. def parse_tool_args(value)
  301. return {} if value.blank?
  302. return value if value.is_a?(Hash)
  303. JSON.parse(value.to_s)
  304. rescue JSON::ParserError
  305. {}
  306. end
  307. def build_response(result, latency_ms)
  308. success_response(
  309. content: result[:content],
  310. latency_ms: latency_ms,
  311. input_tokens: result[:input_tokens],
  312. output_tokens: result[:output_tokens],
  313. provider_request: result[:provider_request],
  314. provider_response: result[:provider_response],
  315. provider_endpoint: result[:provider_endpoint]
  316. ).merge(
  317. tool_calls: result[:tool_calls],
  318. response_id: result[:response_id]
  319. )
  320. end
  321. def handle_json_error(exception)
  322. Rails.logger.error("OpenAI JSON parsing failed: #{exception.message}")
  323. notify_error(exception, operation: "run", error_type: "json_parsing")
  324. error_response(
  325. error: "Invalid JSON response: #{exception.message}",
  326. latency_ms: 0,
  327. error_type: "json_parsing"
  328. )
  329. end
  330. def handle_error(exception)
  331. Rails.logger.error("OpenAI request failed: #{exception.message}")
  332. http_status = extract_http_status(exception)
  333. error_response_hash = extract_error_response_hash(exception)
  334. notify_error(exception, operation: "run", error_type: "request_failed", http_status: http_status)
  335. error_response(
  336. error: exception.message,
  337. latency_ms: 0,
  338. error_type: exception.class.name,
  339. provider_request: @last_provider_request,
  340. provider_error_response: error_response_hash,
  341. http_status: http_status,
  342. response_headers: error_response_hash&.dig(:headers),
  343. provider_endpoint: @last_provider_endpoint
  344. )
  345. end
  346. def extract_http_status(exception)
  347. return nil unless exception.respond_to?(:response)
  348. response = exception.response
  349. if response.is_a?(Hash)
  350. response[:status] || response["status"] || response[:code] || response["code"]
  351. elsif response.respond_to?(:code)
  352. response.code
  353. elsif response.respond_to?(:status)
  354. response.status
  355. end
  356. end
  357. def extract_response_body(exception)
  358. return nil unless exception.respond_to?(:response)
  359. response = exception.response
  360. if response.is_a?(Hash)
  361. body = response[:body] || response["body"]
  362. body.is_a?(String) ? body : body.to_s
  363. else
  364. response.to_s
  365. end
  366. rescue StandardError
  367. nil
  368. end
  369. def extract_response_headers(exception)
  370. return nil unless exception.respond_to?(:response)
  371. response = exception.response
  372. return response[:headers] || response["headers"] if response.is_a?(Hash)
  373. nil
  374. rescue StandardError
  375. nil
  376. end
  377. def extract_error_response_hash(exception)
  378. http_status = extract_http_status(exception)
  379. body = extract_response_body(exception)
  380. headers = extract_response_headers(exception)
  381. return nil if http_status.blank? && body.blank? && headers.blank?
  382. {
  383. status: http_status,
  384. headers: headers,
  385. body: body
  386. }.compact
  387. end
  388. end
  389. end

app/services/llm_providers/provider_config_helper.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module LlmProviders
  3. # Configuration helper for LLM providers (database-backed)
  4. #
  5. # Provides access to provider configuration from LlmProviderConfig model.
  6. # Used by services to determine which providers to use and in what order.
  7. #
  8. # @example
  9. # LlmProviders::ProviderConfigHelper.default_provider # => "anthropic"
  10. # LlmProviders::ProviderConfigHelper.fallback_providers # => ["openai", "ollama"]
  11. # LlmProviders::ProviderConfigHelper.all_providers # => ["anthropic", "openai", "ollama"]
  12. #
  13. module ProviderConfigHelper
  14. class << self
  15. # Returns the default provider name
  16. #
  17. # @return [String] Default provider name
  18. def default_provider
  19. ::LlmProviderConfig.default_provider&.provider_type || "anthropic"
  20. end
  21. # Returns the list of fallback provider names
  22. #
  23. # @return [Array<String>] Fallback provider names
  24. def fallback_providers
  25. ::LlmProviderConfig.fallback_providers.pluck(:provider_type)
  26. end
  27. # Returns all available providers in priority order
  28. #
  29. # @return [Array<String>] Provider names
  30. def all_providers
  31. ([ default_provider ] + fallback_providers).uniq
  32. end
  33. end
  34. end
  35. end

app/services/markdown_renderer.rb

0.0% lines covered

100.0% branches covered

90 relevant lines. 0 lines covered and 90 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "redcarpet"
  3. require "rouge"
  4. require "rouge/plugins/redcarpet"
  5. # MarkdownRenderer converts markdown text into safe HTML with syntax highlighting.
  6. #
  7. # Uses Redcarpet for markdown parsing, Rouge for syntax highlighting,
  8. # and extracts a table of contents from headings.
  9. #
  10. # @example Basic usage (static method)
  11. # html = MarkdownRenderer.render(markdown_text)
  12. #
  13. # @example With TOC extraction (instance method)
  14. # renderer = MarkdownRenderer.new(markdown)
  15. # result = renderer.render
  16. # result[:html] # => safe HTML string with syntax-highlighted code
  17. # result[:toc] # => [{level: 2, id: "section", text: "Section"}, ...]
  18. # result[:reading_time_minutes] # => Integer
  19. #
  20. class MarkdownRenderer
  21. attr_reader :markdown
  22. def initialize(markdown)
  23. @markdown = markdown.to_s
  24. end
  25. # Instance method for rendering with TOC extraction
  26. # @return [Hash{Symbol=>Object}]
  27. def render
  28. result = self.class.render_with_toc(markdown)
  29. {
  30. html: result[:html],
  31. toc: result[:toc],
  32. reading_time_minutes: reading_time_minutes
  33. }
  34. end
  35. # Static method for simple HTML rendering
  36. # @param text [String] The markdown text to render
  37. # @return [String] Safe HTML string
  38. def self.render(text)
  39. renderer = HtmlRenderer.new
  40. markdown = Redcarpet::Markdown.new(renderer, markdown_extensions)
  41. markdown.render(text).html_safe
  42. end
  43. # Static method for rendering with TOC extraction
  44. # @param text [String] The markdown text to render
  45. # @return [Hash{Symbol=>Object}]
  46. def self.render_with_toc(text)
  47. renderer = HtmlRenderer.new
  48. markdown = Redcarpet::Markdown.new(renderer, markdown_extensions)
  49. html = markdown.render(text).html_safe
  50. { html: html, toc: renderer.toc_items }
  51. end
  52. private
  53. def self.markdown_extensions
  54. {
  55. autolink: true,
  56. tables: true,
  57. fenced_code_blocks: true,
  58. strikethrough: true,
  59. highlight: true,
  60. superscript: true,
  61. underline: true,
  62. no_intra_emphasis: true,
  63. space_after_headers: true,
  64. lax_spacing: true
  65. }
  66. end
  67. # Rough reading time estimate assuming 200 wpm.
  68. # @return [Integer]
  69. def reading_time_minutes
  70. words = markdown.scan(/\b[\p{L}\p{N}']+\b/).size
  71. [ (words / 200.0).ceil, 1 ].max
  72. end
  73. # Inner HTML renderer class with Rouge syntax highlighting
  74. class HtmlRenderer < Redcarpet::Render::HTML
  75. include Rouge::Plugins::Redcarpet
  76. # @return [Array<Hash>] Table of contents entries collected during rendering
  77. attr_reader :toc_items
  78. def initialize(extensions = {})
  79. super(extensions.merge(
  80. hard_wrap: true,
  81. link_attributes: { target: "_blank", rel: "noopener noreferrer" },
  82. with_toc_data: true
  83. ))
  84. @toc_items = []
  85. @heading_ids = Hash.new(0)
  86. end
  87. # Custom block code rendering with Rouge using CSS classes
  88. def block_code(code, language)
  89. language ||= "text"
  90. lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
  91. # Use HTML formatter with CSS classes (not inline styles)
  92. formatter = Rouge::Formatters::HTML.new
  93. highlighted = formatter.format(lexer.lex(code))
  94. lang_label = language != "text" ? %(<span class="code-lang">#{language}</span>) : ""
  95. %(<div class="code-block">#{lang_label}<pre class="highlight #{language}"><code>#{highlighted}</code></pre></div>)
  96. end
  97. # Add classes to paragraphs for styling
  98. def paragraph(text)
  99. %(<p>#{text}</p>\n)
  100. end
  101. # Style blockquotes
  102. def block_quote(quote)
  103. %(<blockquote>#{quote}</blockquote>\n)
  104. end
  105. # Add anchor links to headers and collect TOC
  106. def header(text, header_level)
  107. base_slug = text.downcase.strip.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
  108. base_slug = "section" if base_slug.blank?
  109. @heading_ids[base_slug] += 1
  110. slug = @heading_ids[base_slug] > 1 ? "#{base_slug}-#{@heading_ids[base_slug]}" : base_slug
  111. # Collect TOC items for h2, h3, h4
  112. if header_level >= 2 && header_level <= 4
  113. @toc_items << { level: header_level, id: slug, text: text }
  114. end
  115. %(<h#{header_level} id="#{slug}">#{text}</h#{header_level}>\n)
  116. end
  117. # Style horizontal rules
  118. def hrule
  119. %(<hr class="my-8">\n)
  120. end
  121. # Style tables
  122. def table(header, body)
  123. %(<table class="doc-table"><thead>#{header}</thead><tbody>#{body}</tbody></table>\n)
  124. end
  125. end
  126. end

app/services/oauth_authentication_service.rb

0.0% lines covered

100.0% branches covered

51 relevant lines. 0 lines covered and 51 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for authenticating or creating a user from OAuth data
  3. #
  4. # @example
  5. # service = OauthAuthenticationService.new(auth_hash)
  6. # user = service.run
  7. #
  8. class OauthAuthenticationService
  9. # Initialize the service with OAuth authentication hash
  10. #
  11. # @param [OmniAuth::AuthHash] auth_hash The OAuth authentication data
  12. def initialize(auth_hash)
  13. @auth = auth_hash
  14. @provider = auth_hash.provider
  15. @uid = auth_hash.uid
  16. @email = auth_hash.info.email
  17. @name = auth_hash.info.name
  18. end
  19. # Runs the service to find or create a user from OAuth data
  20. #
  21. # @return [User] The authenticated or created user
  22. # @raise [ActiveRecord::RecordInvalid] If user creation fails
  23. def run
  24. user = find_user_by_oauth || find_user_by_email || create_user
  25. update_oauth_fields(user) unless user.oauth_provider.present?
  26. # Create ConnectedAccount for Google OAuth if it doesn't exist
  27. create_connected_account(user) if @provider == "google_oauth2"
  28. user
  29. end
  30. private
  31. # Finds a user by OAuth provider and UID
  32. #
  33. # @return [User, nil] The user if found
  34. def find_user_by_oauth
  35. User.find_by(oauth_provider: @provider, oauth_uid: @uid)
  36. end
  37. # Finds a user by email address
  38. #
  39. # @return [User, nil] The user if found
  40. def find_user_by_email
  41. User.find_by(email_address: @email)
  42. end
  43. # Creates a new user with OAuth data
  44. #
  45. # @return [User] The newly created user
  46. # @raise [ActiveRecord::RecordInvalid] If validation fails
  47. def create_user
  48. random_password = SecureRandom.hex(32)
  49. User.create!(
  50. email_address: @email,
  51. name: @name,
  52. password: random_password,
  53. password_confirmation: random_password,
  54. oauth_provider: @provider,
  55. oauth_uid: @uid,
  56. email_verified_at: Time.current, # OAuth users are auto-verified
  57. terms_accepted_at: Time.current, # OAuth sign-up implies terms acceptance
  58. terms_accepted: true # Satisfy the validation
  59. )
  60. end
  61. # Updates OAuth fields for an existing user
  62. #
  63. # @param user [User] The user to update
  64. # @return [Boolean] True if update succeeds
  65. def update_oauth_fields(user)
  66. user.update(
  67. oauth_provider: @provider,
  68. oauth_uid: @uid,
  69. email_verified_at: Time.current # Mark as verified when linking OAuth
  70. )
  71. end
  72. # Creates a ConnectedAccount for the user if it doesn't exist
  73. #
  74. # @param user [User] The user to create the connected account for
  75. # @return [ConnectedAccount, nil] The created or existing connected account
  76. def create_connected_account(user)
  77. # Check if account already exists by provider and uid
  78. existing_account = user.connected_accounts.find_by(
  79. provider: @provider,
  80. uid: @uid
  81. )
  82. return existing_account if existing_account
  83. # Create new ConnectedAccount using the from_oauth method
  84. ConnectedAccount.from_oauth(user, @auth)
  85. end
  86. end

app/services/opportunities/create_application_service.rb

0.0% lines covered

100.0% branches covered

142 relevant lines. 0 lines covered and 142 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Opportunities
  3. # Service for creating an interview application from an opportunity
  4. #
  5. # Handles the full apply flow:
  6. # 1. Find or create Company from extracted name
  7. # 2. Find or create JobRole from extracted title
  8. # 3. If URL exists: create JobListing + trigger scraping
  9. # 4. If no URL: create application without job listing
  10. # 5. Link opportunity to interview_application
  11. #
  12. # @example
  13. # service = Opportunities::CreateApplicationService.new(opportunity, user)
  14. # result = service.call
  15. # if result[:success]
  16. # redirect_to result[:application]
  17. # end
  18. #
  19. class CreateApplicationService
  20. attr_reader :opportunity, :user
  21. # Initialize the service
  22. #
  23. # @param opportunity [Opportunity] The opportunity to create an application from
  24. # @param user [User] The user creating the application
  25. def initialize(opportunity, user)
  26. @opportunity = opportunity
  27. @user = user
  28. end
  29. # Creates the interview application
  30. #
  31. # @return [Hash] Result with success status and application
  32. def call
  33. return error_result("Opportunity not found") unless opportunity
  34. return error_result("User not found") unless user
  35. return error_result("Already applied") if opportunity.applied?
  36. ActiveRecord::Base.transaction do
  37. # Find or create company
  38. company = find_or_create_company
  39. # Find or create job role
  40. job_role = find_or_create_job_role
  41. # Prefer an existing job_listing already associated with the opportunity.
  42. job_listing = opportunity.job_listing
  43. # Create job listing if we have a URL and no job_listing is present yet.
  44. job_listing ||= create_job_listing_if_url_present(company, job_role)
  45. # Persist the job listing back onto the opportunity for reuse.
  46. opportunity.update!(job_listing: job_listing) if job_listing.present? && opportunity.job_listing_id != job_listing.id
  47. # Create the interview application
  48. application = create_application(company, job_role, job_listing)
  49. # Link opportunity to application and mark as applied
  50. opportunity.update!(
  51. interview_application: application
  52. )
  53. opportunity.mark_applied!
  54. # Trigger job listing scraping in background if we have a URL
  55. if job_listing.present?
  56. ScrapeJobListingJob.perform_later(job_listing)
  57. end
  58. {
  59. success: true,
  60. application: application,
  61. job_listing: job_listing,
  62. company: company,
  63. job_role: job_role
  64. }
  65. end
  66. rescue ActiveRecord::RecordInvalid => e
  67. Rails.logger.error("Opportunities::CreateApplicationService validation error: #{e.message}")
  68. error_result(e.message)
  69. rescue StandardError => e
  70. Rails.logger.error("Opportunities::CreateApplicationService error: #{e.message}")
  71. error_result("Failed to create application: #{e.message}")
  72. end
  73. private
  74. # Finds or creates a company from the opportunity
  75. #
  76. # @return [Company]
  77. def find_or_create_company
  78. company_name = opportunity.company_name.presence || "Unknown Company"
  79. # Normalize name
  80. normalized_name = normalize_company_name(company_name)
  81. # Try to find existing
  82. company = Company.find_by("LOWER(name) = ?", normalized_name.downcase)
  83. return company if company
  84. # Create new company
  85. Company.create!(name: normalized_name)
  86. end
  87. # Finds or creates a job role from the opportunity
  88. #
  89. # @return [JobRole]
  90. def find_or_create_job_role
  91. role_title = opportunity.job_role_title.presence || "Unknown Position"
  92. # Try to find existing
  93. job_role = JobRole.find_by("LOWER(title) = ?", role_title.downcase)
  94. return assign_department_if_missing(job_role) if job_role
  95. # Create new job role with department if available
  96. job_role = JobRole.create!(title: role_title)
  97. assign_department_if_missing(job_role)
  98. end
  99. # Assigns department to job role if not already set
  100. #
  101. # @param job_role [JobRole]
  102. # @return [JobRole]
  103. def assign_department_if_missing(job_role)
  104. return job_role if job_role.category_id.present?
  105. # Try to get department from extracted data
  106. department_name = opportunity.extracted_data&.dig("job_role_department")
  107. if department_name.present?
  108. department = Category.find_by(name: department_name, kind: :job_role)
  109. job_role.update(category: department) if department
  110. else
  111. # Try to infer from title
  112. department = infer_department_from_title(job_role.title)
  113. job_role.update(category: department) if department
  114. end
  115. job_role
  116. end
  117. # Infers department from job role title using keyword matching
  118. #
  119. # @param title [String]
  120. # @return [Category, nil]
  121. def infer_department_from_title(title)
  122. return nil if title.blank?
  123. title_lower = title.downcase
  124. department_keywords = {
  125. "Engineering" => %w[engineer developer software backend frontend fullstack architect sre devops platform],
  126. "Product" => %w[product owner manager pm],
  127. "Design" => %w[designer ux ui visual graphic],
  128. "Data Science" => %w[data scientist analyst analytics machine learning ml ai],
  129. "Sales" => %w[sales account executive ae sdr bdr],
  130. "Marketing" => %w[marketing growth seo sem content brand],
  131. "HR/People" => %w[hr human resources people talent recruiter recruiting],
  132. "Finance" => %w[finance accounting financial],
  133. "Executive" => %w[ceo cto coo cfo cmo chief director vp president]
  134. }
  135. department_keywords.each do |dept_name, keywords|
  136. if keywords.any? { |kw| title_lower.include?(kw) }
  137. return Category.find_by(name: dept_name, kind: :job_role)
  138. end
  139. end
  140. nil
  141. end
  142. # Creates a job listing if we have a URL
  143. #
  144. # @param company [Company]
  145. # @param job_role [JobRole]
  146. # @return [JobListing, nil]
  147. def create_job_listing_if_url_present(company, job_role)
  148. return nil unless opportunity.job_url.present?
  149. res = JobListings::UpsertFromUrlService.new(
  150. url: opportunity.job_url,
  151. company: company,
  152. job_role: job_role,
  153. title: opportunity.job_role_title
  154. ).call
  155. res[:job_listing]
  156. end
  157. # Creates the interview application
  158. #
  159. # @param company [Company]
  160. # @param job_role [JobRole]
  161. # @param job_listing [JobListing, nil]
  162. # @return [InterviewApplication]
  163. def create_application(company, job_role, job_listing)
  164. user.interview_applications.create!(
  165. company: company,
  166. job_role: job_role,
  167. job_listing: job_listing,
  168. applied_at: Time.current,
  169. notes: build_application_notes
  170. )
  171. end
  172. # Builds notes for the application from opportunity data
  173. #
  174. # @return [String, nil]
  175. def build_application_notes
  176. notes_parts = []
  177. notes_parts << "Source: #{opportunity.source_type_display}" if opportunity.source_type.present?
  178. if opportunity.recruiter_name.present? || opportunity.recruiter_email.present?
  179. recruiter_info = [ opportunity.recruiter_name, opportunity.recruiter_email ].compact.join(" - ")
  180. notes_parts << "Recruiter: #{recruiter_info}"
  181. end
  182. notes_parts << "Key details: #{opportunity.key_details}" if opportunity.key_details.present?
  183. notes_parts.any? ? notes_parts.join("\n\n") : nil
  184. end
  185. # Normalizes company name
  186. #
  187. # @param name [String]
  188. # @return [String]
  189. def normalize_company_name(name)
  190. # Remove common suffixes
  191. normalized = name.strip
  192. suffixes = [
  193. /\s+inc\.?$/i,
  194. /\s+llc\.?$/i,
  195. /\s+corp\.?$/i,
  196. /\s+ltd\.?$/i,
  197. /\s+co\.?$/i
  198. ]
  199. suffixes.each { |suffix| normalized = normalized.gsub(suffix, "") }
  200. normalized.strip.titleize
  201. end
  202. # Extracts source ID from URL
  203. #
  204. # @param url [String]
  205. # @return [String, nil]
  206. def extract_source_id(url)
  207. match = url.match(%r{/(jobs?|careers?|positions?)/([^/\?]+)})
  208. match ? match[2] : nil
  209. end
  210. # Returns an error result
  211. #
  212. # @param message [String]
  213. # @return [Hash]
  214. def error_result(message)
  215. {
  216. success: false,
  217. error: message,
  218. application: nil
  219. }
  220. end
  221. end
  222. end

app/services/opportunities/extraction_service.rb

0.0% lines covered

100.0% branches covered

143 relevant lines. 0 lines covered and 143 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Opportunities
  3. # Service for AI-powered extraction of job opportunity details from recruiter emails
  4. #
  5. # Uses configured LLM providers to extract structured data like company name,
  6. # job role, links, and key details from unstructured email content.
  7. # Logs all LLM calls to Ai::LlmApiLog for observability.
  8. #
  9. # @example
  10. # service = Opportunities::ExtractionService.new(opportunity)
  11. # result = service.extract
  12. # if result[:success]
  13. # # Update opportunity with extracted data
  14. # end
  15. #
  16. class ExtractionService < ApplicationService
  17. attr_reader :opportunity
  18. # Initialize the service
  19. #
  20. # @param opportunity [Opportunity] The opportunity to extract data for
  21. def initialize(opportunity)
  22. @opportunity = opportunity
  23. end
  24. # Extracts job opportunity data using AI
  25. #
  26. # @return [Hash] Result with success status and extracted data
  27. def extract
  28. return { success: false, error: "No email content available" } unless email_content_available?
  29. # Build prompt with email content
  30. prompt = build_prompt
  31. # Try extraction with LLM providers
  32. result = extract_with_llm(prompt)
  33. if result[:success]
  34. # Update the opportunity with extracted data
  35. update_opportunity(result[:data])
  36. result
  37. else
  38. { success: false, error: result[:error] || "Extraction failed" }
  39. end
  40. rescue StandardError => e
  41. notify_error(
  42. e,
  43. context: "opportunity_extraction",
  44. user: opportunity&.user,
  45. opportunity_id: opportunity&.id
  46. )
  47. { success: false, error: e.message }
  48. end
  49. private
  50. # Checks if email content is available
  51. #
  52. # @return [Boolean]
  53. def email_content_available?
  54. synced_email.present? && (
  55. synced_email.body_preview.present? ||
  56. synced_email.snippet.present? ||
  57. synced_email.subject.present?
  58. )
  59. end
  60. # Returns the associated synced email
  61. #
  62. # @return [SyncedEmail, nil]
  63. def synced_email
  64. @synced_email ||= opportunity.synced_email
  65. end
  66. # Builds the extraction prompt
  67. #
  68. # @return [String]
  69. def build_prompt
  70. subject = synced_email.subject || "(No subject)"
  71. body = synced_email.body_preview || synced_email.snippet || ""
  72. vars = {
  73. subject: subject,
  74. body: body.truncate(4000)
  75. }
  76. Ai::PromptBuilderService.new(
  77. prompt_class: Ai::EmailExtractionPrompt,
  78. variables: vars
  79. ).run
  80. end
  81. # Extracts data using LLM providers
  82. #
  83. # @param prompt [String] The extraction prompt
  84. # @return [Hash] Result with success and data
  85. def extract_with_llm(prompt)
  86. prompt_template = Ai::EmailExtractionPrompt.active_prompt
  87. system_message = prompt_template&.system_prompt.presence || Ai::EmailExtractionPrompt.default_system_prompt
  88. runner = Ai::ProviderRunnerService.new(
  89. provider_chain: provider_chain,
  90. prompt: prompt,
  91. content_size: (synced_email.body_preview || synced_email.snippet || "").bytesize,
  92. system_message: system_message,
  93. provider_for: method(:get_provider_instance),
  94. run_options: { max_tokens: 2000, temperature: 0.1 },
  95. logger_builder: lambda { |provider_name, provider|
  96. Ai::ApiLoggerService.new(
  97. operation_type: :email_extraction,
  98. loggable: opportunity,
  99. provider: provider_name,
  100. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  101. llm_prompt: prompt_template
  102. )
  103. },
  104. operation: :email_extraction,
  105. loggable: opportunity,
  106. user: opportunity&.user,
  107. error_context: {
  108. severity: "warning",
  109. opportunity_id: opportunity&.id
  110. }
  111. )
  112. result = runner.run do |response|
  113. parsed = parse_response(response[:content])
  114. log_data = {
  115. confidence: parsed&.dig(:confidence_score),
  116. company_name: parsed&.dig(:company_name),
  117. job_role_title: parsed&.dig(:job_role_title),
  118. job_url: parsed&.dig(:job_url)
  119. }.compact
  120. confidence = parsed[:confidence_score].to_f
  121. accept = confidence >= 0.5
  122. [ parsed, log_data, accept ]
  123. end
  124. return { success: true, data: result[:parsed], provider: result[:provider] } if result[:success]
  125. { success: false, error: "All providers failed or returned low confidence" }
  126. end
  127. # Returns the provider chain
  128. #
  129. # @return [Array<String>]
  130. def provider_chain
  131. LlmProviders::ProviderConfigHelper.all_providers
  132. end
  133. # Gets a provider instance
  134. #
  135. # @param provider_name [String]
  136. # @return [LlmProviders::BaseProvider, nil]
  137. def get_provider_instance(provider_name)
  138. case provider_name.to_s.downcase
  139. when "openai"
  140. LlmProviders::OpenaiProvider.new
  141. when "anthropic"
  142. LlmProviders::AnthropicProvider.new
  143. when "ollama"
  144. LlmProviders::OllamaProvider.new
  145. else
  146. nil
  147. end
  148. end
  149. # Parses the LLM response
  150. #
  151. # @param response_text [String]
  152. # @return [Hash]
  153. def parse_response(response_text)
  154. return { confidence_score: 0.0 } unless response_text.present?
  155. # Try to extract JSON from the response
  156. json_match = response_text.match(/\{.*\}/m)
  157. return { confidence_score: 0.0 } unless json_match
  158. data = JSON.parse(json_match[0])
  159. data.deep_symbolize_keys
  160. rescue JSON::ParserError
  161. { confidence_score: 0.0 }
  162. end
  163. # Updates the opportunity with extracted data
  164. #
  165. # @param data [Hash] Extracted data
  166. # @return [void]
  167. def update_opportunity(data)
  168. updates = {}
  169. # Basic job info
  170. updates[:company_name] = data[:company_name] if data[:company_name].present?
  171. updates[:job_role_title] = data[:job_role_title] if data[:job_role_title].present?
  172. updates[:job_url] = data[:job_url] if data[:job_url].present?
  173. # Recruiter info
  174. if data[:recruiter_info].is_a?(Hash)
  175. updates[:recruiter_name] = data[:recruiter_info][:name] if data[:recruiter_info][:name].present?
  176. updates[:recruiter_company] = data[:recruiter_info][:company] if data[:recruiter_info][:company].present?
  177. end
  178. # Key details
  179. updates[:key_details] = data[:key_details] if data[:key_details].present?
  180. # Links
  181. updates[:extracted_links] = data[:all_links] if data[:all_links].is_a?(Array)
  182. # Source detection
  183. if data[:is_forwarded]
  184. updates[:source_type] = case data[:original_source]
  185. when "linkedin" then "linkedin_forward"
  186. when "referral" then "referral"
  187. else "other"
  188. end
  189. end
  190. # Store full extraction data including new domain/department fields
  191. updates[:extracted_data] = opportunity.extracted_data.merge(
  192. raw_extraction: data,
  193. extracted_at: Time.current.iso8601,
  194. company_domain: data[:company_domain],
  195. job_role_department: data[:job_role_department]
  196. ).compact
  197. updates[:ai_confidence_score] = data[:confidence_score]
  198. opportunity.update!(updates)
  199. end
  200. end
  201. end

app/services/profile_insights_service.rb

0.0% lines covered

100.0% branches covered

133 relevant lines. 0 lines covered and 133 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. # Service for generating profile insights and statistics
  3. class ProfileInsightsService
  4. # @param user [User] The user to generate insights for
  5. def initialize(user)
  6. @user = user
  7. end
  8. # Generates comprehensive insights for the user
  9. # @return [Hash] Hash containing all insights
  10. def generate_insights
  11. {
  12. stats: interview_stats,
  13. skill_insights: skill_insights,
  14. strengths: top_strengths,
  15. improvements: areas_to_improve,
  16. timeline: learning_timeline,
  17. recent_activity: recent_activity
  18. }
  19. end
  20. # Skill-related insights from user profile
  21. # @return [Hash] Skill statistics and insights
  22. def skill_insights
  23. user_skills = @user.user_skills.includes(:skill_tag)
  24. {
  25. total: user_skills.count,
  26. strong: user_skills.strong_skills.count,
  27. moderate: user_skills.moderate_skills.count,
  28. developing: user_skills.developing_skills.count,
  29. top_skills: user_skills.by_level_desc.limit(5).map { |s| { name: s.skill_name, level: s.aggregated_level.round(1) } },
  30. categories: user_skills.group(:category).count.sort_by { |_, v| -v }.first(5).to_h,
  31. average_level: user_skills.average(:aggregated_level)&.round(1) || 0,
  32. resumes_analyzed: @user.user_resumes.analyzed.count,
  33. matching_target_roles: calculate_target_role_match_percentage(user_skills)
  34. }
  35. end
  36. private
  37. # Interview statistics
  38. # @return [Hash] Statistics about applications
  39. def interview_stats
  40. applications = @user.interview_applications
  41. {
  42. total: applications.count,
  43. by_stage: InterviewApplication::PIPELINE_STAGES.map { |stage|
  44. [ stage, applications.where(pipeline_stage: stage).count ]
  45. }.to_h,
  46. with_feedback: applications.joins(interview_rounds: :interview_feedback).distinct.count,
  47. this_month: applications.where("created_at >= ?", 1.month.ago).count
  48. }
  49. end
  50. # Top strengths based on feedback
  51. # @return [Array<Hash>] Array of strengths with counts
  52. def top_strengths
  53. feedback_entries = InterviewFeedback.joins(interview_round: { interview_application: :user })
  54. .where(users: { id: @user.id })
  55. .where.not(went_well: nil)
  56. # TODO: Implement actual NLP analysis
  57. # For now, return placeholder data based on tags
  58. skill_mentions = {}
  59. feedback_entries.each do |entry|
  60. entry.tag_list.each do |tag|
  61. skill_mentions[tag] ||= 0
  62. skill_mentions[tag] += 1 if entry.went_well.present?
  63. end
  64. end
  65. skill_mentions.sort_by { |_k, v| -v }.first(5).map do |skill, count|
  66. { name: skill, count: count }
  67. end
  68. end
  69. # Areas to improve based on feedback
  70. # @return [Array<Hash>] Array of improvement areas with counts
  71. def areas_to_improve
  72. feedback_entries = InterviewFeedback.joins(interview_round: { interview_application: :user })
  73. .where(users: { id: @user.id })
  74. .where.not(to_improve: nil)
  75. # TODO: Implement actual NLP analysis
  76. # For now, return placeholder data
  77. skill_mentions = {}
  78. feedback_entries.each do |entry|
  79. entry.tag_list.each do |tag|
  80. skill_mentions[tag] ||= 0
  81. skill_mentions[tag] += 1 if entry.to_improve.present?
  82. end
  83. end
  84. skill_mentions.sort_by { |_k, v| -v }.first(5).map do |skill, count|
  85. { name: skill, count: count }
  86. end
  87. end
  88. # Learning timeline showing progress over time
  89. # @return [Array<Hash>] Timeline data
  90. def learning_timeline
  91. applications = @user.interview_applications.order(created_at: :asc).includes(interview_rounds: :interview_feedback)
  92. applications.map do |application|
  93. {
  94. date: application.created_at,
  95. company: application.company.name,
  96. role: application.job_role.title,
  97. stage: application.pipeline_stage,
  98. has_feedback: application.interview_rounds.joins(:interview_feedback).any?,
  99. sentiment: calculate_sentiment(application)
  100. }
  101. end
  102. end
  103. # Recent activity
  104. # @return [Array<Hash>] Recent activities
  105. def recent_activity
  106. activities = []
  107. # Recent applications
  108. @user.interview_applications.order(created_at: :desc).limit(5).each do |application|
  109. activities << {
  110. type: :application,
  111. date: application.created_at,
  112. description: "Added application at #{application.company.name}",
  113. icon: :briefcase
  114. }
  115. end
  116. # Recent feedback
  117. InterviewFeedback.joins(interview_round: { interview_application: :user })
  118. .where(users: { id: @user.id })
  119. .order(created_at: :desc)
  120. .limit(5)
  121. .includes(interview_round: { interview_application: :company })
  122. .each do |feedback|
  123. activities << {
  124. type: :feedback,
  125. date: feedback.created_at,
  126. description: "Added feedback for #{feedback.interview_round.interview_application.company.name}",
  127. icon: :document
  128. }
  129. end
  130. activities.sort_by { |a| a[:date] }.reverse.first(10)
  131. end
  132. # Calculate sentiment of application based on feedback
  133. # @param application [InterviewApplication] The application to analyze
  134. # @return [String] positive, neutral, or negative
  135. def calculate_sentiment(application)
  136. feedbacks = application.interview_rounds.joins(:interview_feedback).map(&:interview_feedback)
  137. return "neutral" unless feedbacks.any?
  138. # TODO: Implement actual sentiment analysis
  139. latest_feedback = feedbacks.sort_by(&:created_at).last
  140. if latest_feedback.went_well.present? && latest_feedback.to_improve.blank?
  141. "positive"
  142. elsif latest_feedback.went_well.blank? && latest_feedback.to_improve.present?
  143. "negative"
  144. else
  145. "neutral"
  146. end
  147. end
  148. # Calculate percentage of skills matching target roles
  149. # @param user_skills [ActiveRecord::Relation] User skills relation
  150. # @return [Integer] Percentage of matching skills (0-100)
  151. def calculate_target_role_match_percentage(user_skills)
  152. target_roles = @user.target_job_roles
  153. return 0 if target_roles.empty? || user_skills.empty?
  154. # Get required skills from target roles via application skill tags
  155. target_role_skill_ids = ApplicationSkillTag.joins(:interview_application)
  156. .where(interview_applications: { job_role_id: target_roles.pluck(:id) })
  157. .distinct
  158. .pluck(:skill_tag_id)
  159. return 0 if target_role_skill_ids.empty?
  160. # Calculate overlap
  161. user_skill_ids = user_skills.pluck(:skill_tag_id)
  162. matching_skills = (user_skill_ids & target_role_skill_ids).count
  163. ((matching_skills.to_f / target_role_skill_ids.count) * 100).round
  164. end
  165. end

app/services/quick_apply_from_url_service.rb

0.0% lines covered

100.0% branches covered

174 relevant lines. 0 lines covered and 174 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "timeout"
  3. # Service for quick application creation from URL
  4. #
  5. # Orchestrates the entire quick apply flow:
  6. # 1. Creates JobListing with URL
  7. # 2. Runs extraction to get job details
  8. # 3. Extracts company name and job role title
  9. # 4. Creates/finds Company and JobRole
  10. # 5. Updates JobListing with all data
  11. # 6. Creates InterviewApplication
  12. #
  13. # @example
  14. # service = QuickApplyFromUrlService.new("https://boards.greenhouse.io/stripe/jobs/123", user)
  15. # result = service.call
  16. # if result[:success]
  17. # application = result[:application]
  18. # end
  19. class QuickApplyFromUrlService
  20. EXTRACTION_TIMEOUT = 15.seconds
  21. # Initialize the service with URL and user
  22. #
  23. # @param [String] url The job listing URL
  24. # @param [User] user The user creating the application
  25. def initialize(url, user)
  26. @url = url
  27. @normalized_url = ScrapedJobListingData.normalize_url(url)
  28. @user = user
  29. @start_time = Time.current
  30. end
  31. # Executes the quick apply flow
  32. #
  33. # @return [Hash] Result hash with success status, application, and errors
  34. def call
  35. return error_result("URL is required") if @url.blank?
  36. return error_result("Invalid URL format") unless valid_url?
  37. # Extract company name from URL first (needed to create JobListing)
  38. company_name = extract_company_name_from_url || extract_company_name_from_domain
  39. job_role_title = "Unknown Position" # Placeholder, will be updated after extraction
  40. # Find or create Company and JobRole (with placeholder values)
  41. company = find_or_create_company(company_name)
  42. job_role = find_or_create_job_role(job_role_title)
  43. # Create JobListing with company and job_role (required for validations)
  44. job_listing = create_job_listing(company, job_role)
  45. # Run extraction synchronously
  46. # The orchestrator will extract and update company and job_role on the job listing
  47. run_extraction(job_listing)
  48. # After extraction, use the company and job_role that were set by the orchestrator
  49. # The orchestrator already extracted and updated these fields correctly
  50. job_listing.reload
  51. company = job_listing.company
  52. job_role = job_listing.job_role
  53. # Create InterviewApplication using the company and job_role from the job listing
  54. application = create_application(job_listing, company, job_role)
  55. {
  56. success: true,
  57. application: application,
  58. job_listing: job_listing,
  59. company: company,
  60. job_role: job_role,
  61. extraction_time: Time.current - @start_time
  62. }
  63. rescue => e
  64. Rails.logger.error("QuickApplyFromUrlService failed: #{e.message}")
  65. Rails.logger.error(e.backtrace.join("\n"))
  66. error_result(e.message)
  67. end
  68. private
  69. # Validates URL format
  70. #
  71. # @return [Boolean] True if URL is valid
  72. def valid_url?
  73. uri = URI.parse(@url)
  74. uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
  75. rescue URI::InvalidURIError
  76. false
  77. end
  78. # Creates a JobListing with URL, company, and job_role
  79. #
  80. # @param [Company] company The company
  81. # @param [JobRole] job_role The job role
  82. # @return [JobListing] The created job listing
  83. def create_job_listing(company, job_role)
  84. job_listing = JobListing.find_or_initialize_by(url: @normalized_url)
  85. # Set attributes if it's a new record
  86. if job_listing.new_record?
  87. job_listing.company = company
  88. job_listing.job_role = job_role
  89. job_listing.status = :active
  90. job_listing.source_id = extract_source_id(@normalized_url)
  91. job_listing.custom_sections = (job_listing.custom_sections || {}).merge("original_url" => @url)
  92. job_listing.save!
  93. end
  94. job_listing
  95. end
  96. # Runs extraction synchronously with timeout
  97. #
  98. # @param [JobListing] job_listing The job listing to extract for
  99. # @return [Hash] Result hash with success status and extracted data
  100. def run_extraction(job_listing)
  101. # Use orchestrator service for extraction
  102. orchestrator = Scraping::OrchestratorService.new(job_listing)
  103. # Run with timeout - but use a thread so we don't interrupt the orchestrator
  104. # This allows the extraction to continue even if we timeout waiting for it
  105. extraction_thread = Thread.new do
  106. Thread.current[:result] = orchestrator.call
  107. end
  108. # Wait for completion with timeout
  109. completed = extraction_thread.join(EXTRACTION_TIMEOUT)
  110. if completed
  111. success = extraction_thread[:result]
  112. job_listing.reload
  113. if success && job_listing.extraction_completed?
  114. {
  115. success: true,
  116. data: {}
  117. }
  118. else
  119. {
  120. success: false,
  121. error: "Extraction failed"
  122. }
  123. end
  124. else
  125. # Timeout waiting for extraction - it's still running in the background thread
  126. # Queue a background job to handle completion/retry
  127. handle_extraction_timeout(job_listing)
  128. end
  129. rescue => e
  130. Rails.logger.error("Extraction error: #{e.message}")
  131. Rails.logger.error(e.backtrace.join("\n"))
  132. {
  133. success: false,
  134. error: e.message
  135. }
  136. end
  137. # Handles timeout by queuing background job if needed
  138. #
  139. # @param job_listing [JobListing] The job listing being extracted
  140. # @return [Hash] Result hash
  141. def handle_extraction_timeout(job_listing)
  142. latest_attempt = job_listing.scraping_attempts.order(created_at: :desc).first
  143. # Always queue a background job to monitor/complete the extraction
  144. # The extraction might still be running, but if it fails, the job will handle it
  145. if latest_attempt
  146. # Queue with delay to give the current extraction time to complete
  147. ScrapeJobListingJob.set(wait: 30.seconds).perform_later(job_listing)
  148. Rails.logger.info({
  149. event: "extraction_timeout_job_queued",
  150. job_listing_id: job_listing.id,
  151. scraping_attempt_id: latest_attempt.id,
  152. attempt_status: latest_attempt.status
  153. }.to_json)
  154. end
  155. {
  156. success: false,
  157. error: "Extraction is taking longer than expected. Processing in background..."
  158. }
  159. end
  160. # Extracts company name from various sources
  161. #
  162. # @param [JobListing] job_listing The job listing
  163. # @param [Hash] extracted_data The extracted data
  164. # @return [String] Company name
  165. def extract_company_name(job_listing, extracted_data)
  166. # Try company slug from URL first
  167. company_name = extract_company_name_from_url
  168. # If we have extracted data with company field, use that
  169. company_name = extracted_data[:company] if extracted_data[:company].present?
  170. # Fallback to domain name
  171. company_name ||= extract_company_name_from_domain
  172. normalize_company_name(company_name)
  173. end
  174. # Extracts company name from URL patterns
  175. #
  176. # @return [String, nil] Company name or nil
  177. def extract_company_name_from_url
  178. detector = Scraping::JobBoardDetectorService.new(@url)
  179. company_slug = detector.company_slug
  180. return nil unless company_slug
  181. # Convert slug to readable name
  182. # e.g., "stripe" -> "Stripe", "acme-corp" -> "Acme Corp"
  183. company_slug
  184. .gsub(/[-_]/, " ")
  185. .split
  186. .map(&:capitalize)
  187. .join(" ")
  188. end
  189. # Extracts company name from domain
  190. #
  191. # @return [String] Company name from domain
  192. def extract_company_name_from_domain
  193. uri = URI.parse(@url)
  194. domain = uri.host
  195. # Remove www. prefix
  196. domain = domain.sub(/^www\./, "")
  197. # Extract base domain (e.g., "stripe.com" -> "stripe")
  198. base = domain.split(".").first
  199. # Convert to readable name
  200. base.gsub(/[-_]/, " ").split.map(&:capitalize).join(" ")
  201. rescue
  202. "Unknown Company"
  203. end
  204. # Extracts company from scraped data if available
  205. #
  206. # @param [JobListing] job_listing The job listing
  207. # @return [String, nil] Company name or nil
  208. def extract_company_from_scraped_data(job_listing)
  209. # Check if company name is in custom_sections or scraped_data
  210. job_listing.custom_sections&.dig("company") ||
  211. job_listing.scraped_data&.dig("company")
  212. end
  213. # Extracts job role title from scraped data
  214. #
  215. # @param [JobListing] job_listing The job listing
  216. # @param [Hash] extracted_data The extracted data
  217. # @return [String] Job role title
  218. def extract_job_role_title(job_listing, extracted_data)
  219. title = extracted_data[:title] || job_listing.title
  220. return "Unknown Position" if title.blank?
  221. normalize_job_role_title(title)
  222. end
  223. # Normalizes company name for matching
  224. #
  225. # @param [String] name The company name
  226. # @return [String] Normalized name
  227. def normalize_company_name(name)
  228. return "Unknown Company" if name.blank?
  229. name.strip.titleize
  230. end
  231. # Normalizes job role title for matching
  232. #
  233. # @param [String] title The job role title
  234. # @return [String] Normalized title
  235. def normalize_job_role_title(title)
  236. return "Unknown Position" if title.blank?
  237. title.strip
  238. end
  239. # Finds or creates a Company
  240. #
  241. # @param [String] name The company name
  242. # @return [Company] The company record
  243. def find_or_create_company(name)
  244. normalized_name = normalize_company_name(name)
  245. Company.find_or_create_by(name: normalized_name) do |company|
  246. # Extract website from URL if possible
  247. uri = URI.parse(@url)
  248. company.website = "#{uri.scheme}://#{uri.host}" if uri.host
  249. end
  250. end
  251. # Finds or creates a JobRole
  252. #
  253. # @param [String] title The job role title
  254. # @return [JobRole] The job role record
  255. def find_or_create_job_role(title)
  256. normalized_title = normalize_job_role_title(title)
  257. JobRole.find_or_create_by(title: normalized_title)
  258. end
  259. # Creates an InterviewApplication
  260. #
  261. # @param [JobListing] job_listing The job listing
  262. # @param [Company] company The company
  263. # @param [JobRole] job_role The job role
  264. # @return [InterviewApplication] The created application
  265. def create_application(job_listing, company, job_role)
  266. application = @user.interview_applications.find_or_initialize_by(job_listing: job_listing)
  267. application.company = company
  268. application.job_role = job_role
  269. application.applied_at ||= Date.today
  270. application.save!
  271. application
  272. end
  273. # Extracts source ID from URL
  274. #
  275. # @param [String] url The URL
  276. # @return [String, nil] Source ID or nil
  277. def extract_source_id(url)
  278. match = url.match(%r{/(jobs?|careers?|positions?)/([^/\?]+)})
  279. match ? match[2] : nil
  280. end
  281. # Returns an error result hash
  282. #
  283. # @param [String] error_message The error message
  284. # @return [Hash] Error result hash
  285. def error_result(error_message)
  286. {
  287. success: false,
  288. error: error_message,
  289. application: nil
  290. }
  291. end
  292. end

app/services/resumes/ai_skill_extractor_service.rb

0.0% lines covered

100.0% branches covered

320 relevant lines. 0 lines covered and 320 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Resumes
  3. # Service for AI-powered skill extraction from resume text
  4. #
  5. # Uses configured LLM providers to extract structured skill data,
  6. # with automatic fallback to alternative providers on failure.
  7. # Logs all LLM calls to Ai::LlmApiLog for observability.
  8. #
  9. # @example
  10. # extractor = Resumes::AiSkillExtractorService.new(user_resume)
  11. # result = extractor.extract
  12. # if result[:success]
  13. # result[:skills].each do |skill|
  14. # puts "#{skill[:name]}: #{skill[:proficiency]}/5"
  15. # end
  16. # end
  17. #
  18. class AiSkillExtractorService < ApplicationService
  19. attr_reader :user_resume
  20. # Minimum confidence threshold to accept extraction
  21. MIN_CONFIDENCE = 0.6
  22. # Initialize the service
  23. #
  24. # @param user_resume [UserResume] The resume to analyze
  25. def initialize(user_resume)
  26. @user_resume = user_resume
  27. end
  28. # Extracts skills from the resume text using AI
  29. #
  30. # @return [Hash] Result with :success, :skills, :summary, :confidence keys
  31. def extract
  32. text = user_resume.parsed_text
  33. return error_result("No parsed text available") if text.blank?
  34. prompt = build_extraction_prompt(text)
  35. result = extract_with_providers(prompt, text.bytesize)
  36. return error_result(result[:error]) unless result[:success]
  37. existing_extracted_data = coerce_extracted_data_hash(user_resume.extracted_data)
  38. # Store structured extraction output for traceability and downstream profile features.
  39. #
  40. # Keep both:
  41. # - parsed: normalized, structured output used by the app
  42. # - raw_response: original assistant text (best-effort, truncated by DB logger elsewhere)
  43. user_resume.update!(
  44. extracted_data: existing_extracted_data.merge(
  45. "resume_extraction" => {
  46. "extracted_at" => Time.current.iso8601,
  47. "parsed" => {
  48. "skills" => result[:skills],
  49. "work_history" => result[:work_history],
  50. "summary" => result[:summary],
  51. "overall_confidence" => result[:confidence],
  52. "strengths" => result[:strengths],
  53. "domains" => result[:domains],
  54. "resume_date" => result[:resume_date],
  55. "resume_date_confidence" => result[:resume_date_confidence],
  56. "resume_date_source" => result[:resume_date_source]
  57. },
  58. "raw_response" => result[:raw_response].to_s.truncate(50_000)
  59. }
  60. )
  61. )
  62. success_result(result)
  63. rescue StandardError => e
  64. notify_error(
  65. e,
  66. context: "resume_skill_extraction",
  67. user: user_resume&.user,
  68. user_resume_id: user_resume&.id
  69. )
  70. error_result(e.message)
  71. end
  72. private
  73. # Extracts using provider chain with fallback
  74. #
  75. # @param prompt [String] The extraction prompt
  76. # @param content_size [Integer] Size of resume text in bytes
  77. # @return [Hash] Extraction result
  78. def extract_with_providers(prompt, content_size)
  79. prompt_template = Ai::ResumeSkillExtractionPrompt.active_prompt
  80. system_message = prompt_template&.system_prompt.presence || Ai::ResumeSkillExtractionPrompt.default_system_prompt
  81. runner = Ai::ProviderRunnerService.new(
  82. provider_chain: provider_chain,
  83. prompt: prompt,
  84. content_size: content_size,
  85. system_message: system_message,
  86. provider_for: method(:get_provider_instance),
  87. logger_builder: lambda { |provider_name, provider|
  88. Ai::ApiLoggerService.new(
  89. operation_type: :resume_extraction,
  90. loggable: user_resume,
  91. provider: provider_name,
  92. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  93. llm_prompt: prompt_template
  94. )
  95. },
  96. operation: :resume_extraction,
  97. loggable: user_resume,
  98. user: user_resume&.user,
  99. error_context: {
  100. severity: "warning",
  101. user_resume_id: user_resume&.id
  102. }
  103. )
  104. result = runner.run do |response|
  105. parsed = parse_response(response[:content])
  106. log_data = {
  107. confidence: parsed&.dig(:confidence)
  108. }
  109. if parsed.present? && parsed[:skills].present?
  110. log_data.merge!(
  111. skills: parsed[:skills],
  112. summary: parsed[:summary],
  113. strengths: parsed[:strengths],
  114. domains: parsed[:domains]
  115. )
  116. end
  117. confidence = parsed[:confidence].to_f
  118. accept = confidence >= MIN_CONFIDENCE
  119. [ parsed, log_data, accept ]
  120. end
  121. return { success: false, error: "All providers failed or returned low confidence" } unless result[:success]
  122. raw_response = result.dig(:result, :raw_response)
  123. result[:parsed].merge(
  124. success: true,
  125. provider: result[:provider],
  126. model: result[:model],
  127. raw_response: raw_response
  128. )
  129. end
  130. # Builds the extraction prompt
  131. #
  132. # @param text [String] Resume text
  133. # @return [String] Complete prompt
  134. def build_extraction_prompt(text)
  135. prompt_template = Ai::ResumeSkillExtractionPrompt.active_prompt
  136. if prompt_template
  137. prompt_template.build_prompt(resume_text: text.truncate(15000))
  138. else
  139. Ai::ResumeSkillExtractionPrompt.default_prompt_template
  140. .gsub("{{resume_text}}", text.truncate(15000))
  141. end
  142. end
  143. # Parses the AI response
  144. #
  145. # @param response_text [String] Raw AI response
  146. # @return [Hash] Parsed data
  147. def parse_response(response_text)
  148. return { skills: [], error: "No response" } unless response_text.present?
  149. data = extract_json_object(response_text)
  150. return { skills: [], error: "No JSON found in response" } unless data
  151. normalize_parsed_data(data)
  152. rescue JSON::ParserError => e
  153. Rails.logger.error("Failed to parse skill extraction response: #{e.message}")
  154. { skills: [], error: "Invalid JSON response" }
  155. end
  156. # Coerces extracted_data into a Hash.
  157. #
  158. # Older records may have extracted_data stored as a JSON string in a jsonb column.
  159. #
  160. # @param value [Object]
  161. # @return [Hash]
  162. def coerce_extracted_data_hash(value)
  163. return {} if value.blank?
  164. return value if value.is_a?(Hash)
  165. if value.is_a?(String)
  166. parsed = (JSON.parse(value) rescue nil)
  167. return parsed if parsed.is_a?(Hash)
  168. end
  169. {}
  170. end
  171. # Extracts a JSON object from an LLM response string.
  172. #
  173. # Handles:
  174. # - raw JSON
  175. # - markdown fenced blocks: ```json { ... } ```
  176. # - extra prose around JSON
  177. #
  178. # @param text [String]
  179. # @return [Hash, nil]
  180. def extract_json_object(text)
  181. str = text.to_s
  182. fenced = str.match(/```json\s*(\{.*?\})\s*```/m)
  183. if fenced
  184. parsed = (JSON.parse(fenced[1]) rescue nil)
  185. return parsed if parsed.is_a?(Hash)
  186. end
  187. start_idx = str.index("{")
  188. end_idx = str.rindex("}")
  189. return nil if start_idx.nil? || end_idx.nil? || end_idx <= start_idx
  190. candidate = str[start_idx..end_idx]
  191. parsed = (JSON.parse(candidate) rescue nil)
  192. parsed.is_a?(Hash) ? parsed : nil
  193. end
  194. # Normalizes parsed data
  195. #
  196. # @param data [Hash] Raw parsed data
  197. # @return [Hash] Normalized data
  198. def normalize_parsed_data(data)
  199. skills = (data["skills"] || []).map do |skill|
  200. {
  201. name: skill["name"]&.strip,
  202. category: normalize_category(skill["category"]),
  203. proficiency: skill["proficiency"]&.to_i&.clamp(1, 5) || 3,
  204. confidence: skill["confidence"]&.to_f&.clamp(0.0, 1.0) || 0.5,
  205. evidence: skill["evidence"]&.truncate(500),
  206. years: skill["years"]&.to_i
  207. }
  208. end.reject { |s| s[:name].blank? }
  209. work_history = Array(data["work_history"]).map do |entry|
  210. normalize_work_history_entry(entry)
  211. end.compact
  212. {
  213. skills: skills,
  214. work_history: work_history,
  215. summary: data["summary"],
  216. confidence: data["overall_confidence"]&.to_f || 0.5,
  217. strengths: Array(data["strengths"]),
  218. domains: Array(data["domains"]),
  219. resume_date: parse_resume_date(data["resume_date"]),
  220. resume_date_confidence: data["resume_date_confidence"],
  221. resume_date_source: data["resume_date_source"]
  222. }
  223. end
  224. # Normalizes an extracted work history entry.
  225. #
  226. # Supports both legacy (company/role/duration) and expanded schema:
  227. # start_date/end_date/current/responsibilities/highlights/skills_used/company_domain/role_department.
  228. #
  229. # @param entry [Hash]
  230. # @return [Hash, nil]
  231. def normalize_work_history_entry(entry)
  232. return nil unless entry.is_a?(Hash)
  233. company = (entry["company"] || entry[:company]).to_s.strip
  234. role = (entry["role"] || entry["title"] || entry[:role] || entry[:title]).to_s.strip
  235. duration_text = (entry["duration"] || entry[:duration]).to_s.strip
  236. # New fields for domain and department
  237. company_domain = (entry["company_domain"] || entry[:company_domain]).to_s.strip.presence
  238. role_department = normalize_department(entry["role_department"] || entry[:role_department])
  239. start_date = parse_flexible_date(entry["start_date"] || entry[:start_date] || entry["start"] || entry[:start])
  240. end_date = parse_flexible_date(entry["end_date"] || entry[:end_date] || entry["end"] || entry[:end])
  241. current =
  242. if entry.key?("current") || entry.key?(:current)
  243. !!(entry["current"] || entry[:current])
  244. else
  245. false
  246. end
  247. responsibilities = normalize_text_array(entry["responsibilities"] || entry[:responsibilities] || entry["responsibility"] || entry[:responsibility])
  248. highlights = normalize_text_array(entry["highlights"] || entry[:highlights] || entry["achievements"] || entry[:achievements])
  249. skills_used = normalize_skill_refs(
  250. entry["skills_used"] || entry[:skills_used] ||
  251. entry["skills"] || entry[:skills] ||
  252. entry["technologies"] || entry[:technologies]
  253. )
  254. normalized = {
  255. company: company.presence,
  256. company_domain: company_domain,
  257. role: role.presence,
  258. role_department: role_department,
  259. duration: duration_text.presence,
  260. start_date: start_date,
  261. end_date: end_date,
  262. current: current,
  263. responsibilities: responsibilities,
  264. highlights: highlights,
  265. skills_used: skills_used
  266. }.compact
  267. return nil if normalized.except(:responsibilities, :highlights, :skills_used, :current, :company_domain, :role_department).blank?
  268. # Always include these arrays/flags for consistent downstream usage.
  269. normalized[:responsibilities] ||= []
  270. normalized[:highlights] ||= []
  271. normalized[:skills_used] ||= []
  272. normalized[:current] = !!normalized[:current]
  273. normalized
  274. end
  275. # Normalizes department name to match our valid departments
  276. #
  277. # @param department [String, nil]
  278. # @return [String, nil]
  279. def normalize_department(department)
  280. return nil if department.blank?
  281. dept = department.to_s.strip
  282. valid_departments = [
  283. "Engineering", "Product", "Design", "Data Science", "DevOps/SRE",
  284. "Sales", "Marketing", "Customer Success", "Finance", "HR/People",
  285. "Legal", "Operations", "Executive", "Research", "QA/Testing",
  286. "Security", "IT", "Content", "Other"
  287. ]
  288. # Exact match
  289. return dept if valid_departments.include?(dept)
  290. # Case-insensitive match
  291. matched = valid_departments.find { |d| d.downcase == dept.downcase }
  292. return matched if matched
  293. # Partial match for common variations
  294. dept_lower = dept.downcase
  295. return "Engineering" if dept_lower.include?("engineer") || dept_lower.include?("develop") || dept_lower.include?("tech")
  296. return "Product" if dept_lower.include?("product")
  297. return "Design" if dept_lower.include?("design") || dept_lower.include?("ux") || dept_lower.include?("ui")
  298. return "Data Science" if dept_lower.include?("data") || dept_lower.include?("analyt")
  299. return "DevOps/SRE" if dept_lower.include?("devops") || dept_lower.include?("sre") || dept_lower.include?("infrastructure")
  300. return "Sales" if dept_lower.include?("sales")
  301. return "Marketing" if dept_lower.include?("market") || dept_lower.include?("growth")
  302. return "HR/People" if dept_lower.include?("hr") || dept_lower.include?("human") || dept_lower.include?("people") || dept_lower.include?("talent")
  303. return "Finance" if dept_lower.include?("finance") || dept_lower.include?("account")
  304. return "Legal" if dept_lower.include?("legal")
  305. return "Executive" if dept_lower.include?("executive") || dept_lower.include?("leadership") || dept_lower.include?("c-suite")
  306. return "QA/Testing" if dept_lower.include?("qa") || dept_lower.include?("quality") || dept_lower.include?("test")
  307. return "Security" if dept_lower.include?("security")
  308. return "Customer Success" if dept_lower.include?("customer") || dept_lower.include?("support")
  309. nil
  310. end
  311. # Parses flexible date formats (YYYY-MM-DD, YYYY-MM, YYYY).
  312. #
  313. # @param value [String, nil]
  314. # @return [Date, nil]
  315. def parse_flexible_date(value)
  316. str = value.to_s.strip
  317. return nil if str.blank?
  318. if str.match?(/\A\d{4}-\d{2}-\d{2}\z/)
  319. Date.parse(str)
  320. elsif str.match?(/\A\d{4}-\d{2}\z/)
  321. year, month = str.split("-").map(&:to_i)
  322. Date.new(year, month, 1)
  323. elsif str.match?(/\A\d{4}\z/)
  324. Date.new(str.to_i, 1, 1)
  325. else
  326. Date.parse(str)
  327. end
  328. rescue ArgumentError
  329. nil
  330. end
  331. # Normalizes a value into an array of non-empty strings.
  332. #
  333. # @param value [Object]
  334. # @return [Array<String>]
  335. def normalize_text_array(value)
  336. Array(value).map { |v| v.to_s.strip }.reject(&:blank?).first(50)
  337. end
  338. # Normalizes a “skills used” payload into a list of hashes.
  339. #
  340. # Supports:
  341. # - ["Ruby", "Postgres"]
  342. # - [{ "name": "Ruby", "evidence": "...", "confidence": 0.8 }, ...]
  343. #
  344. # @param value [Object]
  345. # @return [Array<Hash>]
  346. def normalize_skill_refs(value)
  347. Array(value).map do |item|
  348. if item.is_a?(Hash)
  349. name = (item["name"] || item[:name] || item["skill"] || item[:skill]).to_s.strip
  350. next nil if name.blank?
  351. {
  352. name: name,
  353. confidence: (item["confidence"] || item[:confidence])&.to_f,
  354. evidence: (item["evidence"] || item[:evidence]).to_s.strip.presence
  355. }.compact
  356. else
  357. name = item.to_s.strip
  358. next nil if name.blank?
  359. { name: name }
  360. end
  361. end.compact.uniq { |h| h[:name].to_s.downcase }.first(50)
  362. end
  363. # Parses resume date string to Date object
  364. #
  365. # @param date_string [String, nil] Date in YYYY-MM-DD format
  366. # @return [Date, nil] Parsed date or nil
  367. def parse_resume_date(date_string)
  368. return nil if date_string.blank?
  369. Date.parse(date_string)
  370. rescue ArgumentError
  371. nil
  372. end
  373. # Normalizes category to valid option
  374. #
  375. # @param category [String] Raw category
  376. # @return [String] Normalized category
  377. def normalize_category(category)
  378. valid_categories = ResumeSkill::CATEGORIES
  379. return "Other" if category.blank?
  380. # Try exact match first
  381. return category if valid_categories.include?(category)
  382. # Try case-insensitive match
  383. match = valid_categories.find { |c| c.downcase == category.downcase }
  384. return match if match
  385. # Default to Other
  386. "Other"
  387. end
  388. # Returns the provider chain
  389. #
  390. # @return [Array<String>] Provider names in priority order
  391. def provider_chain
  392. LlmProviders::ProviderConfigHelper.all_providers
  393. end
  394. # Gets a provider instance
  395. #
  396. # @param provider_name [String] Provider name
  397. # @return [LlmProviders::BaseProvider] Provider instance
  398. def get_provider_instance(provider_name)
  399. case provider_name.to_s.downcase
  400. when "openai" then LlmProviders::OpenaiProvider.new
  401. when "anthropic" then LlmProviders::AnthropicProvider.new
  402. when "ollama" then LlmProviders::OllamaProvider.new
  403. else raise ArgumentError, "Unknown provider: #{provider_name}"
  404. end
  405. end
  406. # Builds success result
  407. #
  408. # @param result [Hash] Extraction result
  409. # @return [Hash]
  410. def success_result(result)
  411. {
  412. success: true,
  413. skills: result[:skills],
  414. work_history: result[:work_history] || [],
  415. summary: result[:summary],
  416. confidence: result[:confidence],
  417. strengths: result[:strengths],
  418. domains: result[:domains],
  419. resume_date: result[:resume_date],
  420. resume_date_confidence: result[:resume_date_confidence],
  421. resume_date_source: result[:resume_date_source],
  422. provider: result[:provider],
  423. model: result[:model]
  424. }
  425. end
  426. # Builds error result
  427. #
  428. # @param message [String] Error message
  429. # @return [Hash]
  430. def error_result(message)
  431. {
  432. success: false,
  433. error: message,
  434. skills: []
  435. }
  436. end
  437. end
  438. end

app/services/resumes/analysis_service.rb

0.0% lines covered

100.0% branches covered

208 relevant lines. 0 lines covered and 208 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Resumes
  3. # Service for orchestrating the complete resume analysis pipeline
  4. #
  5. # Pipeline steps:
  6. # 1. Extract text from uploaded file (PDF/DOCX/DOC/TXT)
  7. # 2. Send text to AI for skill extraction
  8. # 3. Create ResumeSkill records from extracted skills
  9. # 4. Trigger UserSkill aggregation
  10. #
  11. # @example
  12. # service = Resumes::AnalysisService.new(user_resume)
  13. # result = service.run
  14. # if result[:success]
  15. # puts "Extracted #{result[:skills_count]} skills"
  16. # end
  17. #
  18. class AnalysisService
  19. attr_reader :user_resume
  20. # Initialize the service
  21. #
  22. # @param user_resume [UserResume] The resume to analyze
  23. def initialize(user_resume)
  24. @user_resume = user_resume
  25. end
  26. # Runs the complete analysis pipeline
  27. #
  28. # @return [Hash] Result with :success, :skills_count, :error keys
  29. def run
  30. user_resume.start_analysis!
  31. # Step 1: Extract text from file
  32. text_result = extract_text
  33. return failure_result(text_result[:error]) unless text_result[:success]
  34. # Step 2: Extract skills using AI
  35. skill_result = extract_skills
  36. return failure_result(skill_result[:error]) unless skill_result[:success]
  37. # Step 3: Create resume skill records
  38. skills_created = create_resume_skills(skill_result[:skills])
  39. # Step 4: Persist expanded work history (experiences + per-experience skills)
  40. persist_work_history(skill_result[:work_history])
  41. # Step 5: Save resume date if extracted
  42. save_resume_date(skill_result)
  43. # Step 6: Aggregate user skills
  44. aggregate_user_skills
  45. # Step 7: Merge work history across resumes into a user-level profile
  46. Resumes::WorkHistoryAggregationService.new(user_resume.user).run
  47. # Persist resume-derived strengths/domains for later display.
  48. persist_strengths_and_domains(skill_result)
  49. # Mark analysis as complete
  50. user_resume.complete_analysis!(summary: skill_result[:summary])
  51. success_result(skills_created, skill_result)
  52. rescue StandardError => e
  53. Rails.logger.error("Resume analysis failed: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
  54. user_resume.fail_analysis!(error_message: e.message)
  55. failure_result(e.message)
  56. end
  57. private
  58. # Step 1: Extract text from the uploaded file
  59. #
  60. # @return [Hash] Extraction result
  61. def extract_text
  62. TextExtractorService.new(user_resume).extract
  63. end
  64. # Step 2: Extract skills using AI
  65. #
  66. # @return [Hash] AI extraction result
  67. def extract_skills
  68. AiSkillExtractorService.new(user_resume).extract
  69. end
  70. # Step 3: Create ResumeSkill records from extracted skills
  71. #
  72. # @param skills [Array<Hash>] Extracted skill data
  73. # @return [Integer] Number of skills created
  74. def create_resume_skills(skills)
  75. return 0 if skills.blank?
  76. created_count = 0
  77. skills.each do |skill_data|
  78. skill_tag = find_or_create_skill_tag(skill_data[:name])
  79. next unless skill_tag
  80. resume_skill = user_resume.resume_skills.find_or_initialize_by(skill_tag: skill_tag)
  81. resume_skill.assign_attributes(
  82. model_level: skill_data[:proficiency],
  83. confidence_score: skill_data[:confidence],
  84. category: skill_data[:category],
  85. evidence_snippet: skill_data[:evidence],
  86. years_of_experience: skill_data[:years]
  87. )
  88. if resume_skill.save
  89. created_count += 1
  90. else
  91. Rails.logger.warn("Failed to create resume skill: #{resume_skill.errors.full_messages.join(", ")}")
  92. end
  93. end
  94. created_count
  95. end
  96. # Finds or creates a skill tag with normalization
  97. #
  98. # @param name [String] Skill name
  99. # @return [SkillTag, nil] The skill tag or nil if invalid
  100. def find_or_create_skill_tag(name)
  101. return nil if name.blank?
  102. SkillTag.find_or_create_by_name(name)
  103. rescue ActiveRecord::RecordInvalid => e
  104. Rails.logger.warn("Failed to create skill tag '#{name}': #{e.message}")
  105. nil
  106. end
  107. # Persists expanded work history into normalized tables.
  108. #
  109. # @param work_history [Array<Hash>] Work history entries
  110. # @return [void]
  111. def persist_work_history(work_history)
  112. return if work_history.blank?
  113. user_resume.resume_work_experiences.destroy_all
  114. work_history.each do |entry|
  115. next unless entry.is_a?(Hash)
  116. company_name = entry[:company].to_s.strip
  117. role_title = entry[:role].to_s.strip
  118. company_domain = entry[:company_domain].to_s.strip.presence
  119. role_department = entry[:role_department].to_s.strip.presence
  120. normalized_company = normalize_company_name(company_name)
  121. normalized_role = normalize_job_title(role_title)
  122. company = normalized_company.present? ? Company.find_or_create_by(name: normalized_company) : nil
  123. job_role = find_or_create_job_role_with_department(normalized_role, role_department)
  124. experience = user_resume.resume_work_experiences.create!(
  125. company: company,
  126. job_role: job_role,
  127. company_name: company_name.presence,
  128. role_title: role_title.presence,
  129. start_date: entry[:start_date],
  130. end_date: entry[:end_date],
  131. current: !!entry[:current],
  132. duration_text: entry[:duration],
  133. responsibilities: Array(entry[:responsibilities]),
  134. highlights: Array(entry[:highlights]),
  135. metadata: { company_domain: company_domain, role_department: role_department }.compact
  136. )
  137. Array(entry[:skills_used]).each do |skill_ref|
  138. next unless skill_ref.is_a?(Hash)
  139. name = skill_ref[:name].to_s.strip
  140. next if name.blank?
  141. skill_tag = SkillTag.find_or_create_by_name(name)
  142. experience.resume_work_experience_skills.find_or_create_by!(skill_tag: skill_tag) do |row|
  143. row.confidence_score = skill_ref[:confidence]
  144. row.evidence_snippet = skill_ref[:evidence]
  145. end
  146. end
  147. end
  148. rescue StandardError => e
  149. Rails.logger.warn("Failed to persist work history: #{e.message}")
  150. end
  151. # Finds or creates a job role and assigns department if provided
  152. #
  153. # @param title [String] Job role title
  154. # @param department_name [String, nil] Department name
  155. # @return [JobRole, nil]
  156. def find_or_create_job_role_with_department(title, department_name)
  157. return nil if title.blank?
  158. job_role = JobRole.find_or_create_by(title: title)
  159. # Assign department if provided and role doesn't have one
  160. if department_name.present? && job_role.category_id.nil?
  161. department = Category.find_by(name: department_name, kind: :job_role)
  162. job_role.update(category: department) if department
  163. elsif job_role.category_id.nil?
  164. # Try to infer department from title
  165. department = infer_department_from_title(title)
  166. job_role.update(category: department) if department
  167. end
  168. job_role
  169. end
  170. # Infers department from job role title using keyword matching
  171. #
  172. # @param title [String] Job role title
  173. # @return [Category, nil]
  174. def infer_department_from_title(title)
  175. return nil if title.blank?
  176. title_lower = title.downcase
  177. department_keywords = {
  178. "Engineering" => %w[engineer developer software backend frontend fullstack architect sre devops platform],
  179. "Product" => %w[product owner manager pm],
  180. "Design" => %w[designer ux ui visual graphic],
  181. "Data Science" => %w[data scientist analyst analytics machine learning ml ai],
  182. "DevOps/SRE" => %w[devops sre infrastructure reliability platform],
  183. "Sales" => %w[sales account executive ae sdr bdr],
  184. "Marketing" => %w[marketing growth seo sem content brand],
  185. "Customer Success" => %w[customer success support cx],
  186. "Finance" => %w[finance accounting financial controller cfo],
  187. "HR/People" => %w[hr human resources people talent recruiter recruiting],
  188. "Legal" => %w[legal counsel attorney compliance],
  189. "Operations" => %w[operations ops logistics supply],
  190. "Executive" => %w[ceo cto coo cfo cmo chief director vp president],
  191. "Research" => %w[research scientist r&d],
  192. "QA/Testing" => %w[qa quality assurance test tester sdet],
  193. "Security" => %w[security infosec appsec cyber],
  194. "IT" => %w[it helpdesk administrator admin sysadmin],
  195. "Content" => %w[content writer editor copywriter]
  196. }
  197. department_keywords.each do |dept_name, keywords|
  198. if keywords.any? { |kw| title_lower.include?(kw) }
  199. return Category.find_by(name: dept_name, kind: :job_role)
  200. end
  201. end
  202. nil
  203. end
  204. # Normalizes company name
  205. #
  206. # @param name [String] Raw company name
  207. # @return [String, nil] Normalized name or nil if invalid
  208. def normalize_company_name(name)
  209. return nil if name.blank?
  210. # Remove common suffixes and clean up
  211. normalized = name.strip
  212. .gsub(/\s+(Inc\.?|LLC|Ltd\.?|Corp\.?|Corporation|Company|Co\.?)$/i, "")
  213. .strip
  214. # Skip if too short or looks like garbage
  215. return nil if normalized.length < 2
  216. return nil if normalized =~ /^[^a-zA-Z]*$/
  217. normalized
  218. end
  219. # Normalizes job title
  220. #
  221. # @param title [String] Raw job title
  222. # @return [String, nil] Normalized title or nil if invalid
  223. def normalize_job_title(title)
  224. return nil if title.blank?
  225. normalized = title.strip
  226. # Skip if too short or looks like garbage
  227. return nil if normalized.length < 2
  228. return nil if normalized =~ /^[^a-zA-Z]*$/
  229. normalized
  230. end
  231. # Saves resume date if extracted by AI
  232. #
  233. # @param skill_result [Hash] AI extraction result
  234. # @return [void]
  235. def save_resume_date(skill_result)
  236. return unless skill_result[:resume_date].present?
  237. user_resume.update!(
  238. resume_updated_at: skill_result[:resume_date],
  239. resume_date_confidence: skill_result[:resume_date_confidence],
  240. resume_date_source: skill_result[:resume_date_source]
  241. )
  242. rescue StandardError => e
  243. Rails.logger.warn("Failed to save resume date: #{e.message}")
  244. end
  245. # Aggregates skills for the user
  246. def aggregate_user_skills
  247. aggregation_service = SkillAggregationService.new(user_resume.user)
  248. # Aggregate only skills from this resume
  249. user_resume.skill_tags.each do |skill_tag|
  250. aggregation_service.aggregate_skill(skill_tag)
  251. end
  252. end
  253. # Persists resume-derived strengths and domains returned by the extractor.
  254. #
  255. # @param skill_result [Hash]
  256. # @return [void]
  257. def persist_strengths_and_domains(skill_result)
  258. strengths = Array(skill_result[:strengths])
  259. .map { |s| s.to_s.strip }
  260. .reject(&:blank?)
  261. # De-dupe near-duplicates within the same resume analysis (conservative threshold).
  262. strengths = Labels::DedupeService.new(strengths, similarity_threshold: 0.9, overlap_threshold: 0.85).run
  263. domains = Array(skill_result[:domains])
  264. .map { |d| d.to_s.strip }
  265. .reject(&:blank?)
  266. .uniq
  267. user_resume.update!(strengths: strengths, domains: domains)
  268. rescue StandardError => e
  269. Rails.logger.warn("Failed to persist strengths/domains: #{e.message}")
  270. end
  271. # Builds success result
  272. #
  273. # @param skills_count [Integer] Number of skills created
  274. # @param skill_result [Hash] AI extraction result
  275. # @return [Hash]
  276. def success_result(skills_count, skill_result)
  277. {
  278. success: true,
  279. skills_count: skills_count,
  280. summary: skill_result[:summary],
  281. strengths: skill_result[:strengths],
  282. domains: skill_result[:domains],
  283. provider: skill_result[:provider],
  284. model: skill_result[:model]
  285. }
  286. end
  287. # Builds failure result
  288. #
  289. # @param error [String] Error message
  290. # @return [Hash]
  291. def failure_result(error)
  292. {
  293. success: false,
  294. error: error,
  295. skills_count: 0
  296. }
  297. end
  298. end
  299. end

app/services/resumes/skill_aggregation_service.rb

0.0% lines covered

100.0% branches covered

129 relevant lines. 0 lines covered and 129 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Resumes
  3. # Service for aggregating skills from multiple resumes into a unified user profile
  4. #
  5. # Uses weighted averaging based on:
  6. # - Recency: Newer resumes have higher weight
  7. # - Purpose: Role-specific resumes get slightly higher weight
  8. # - User confirmation: User-adjusted levels take precedence
  9. #
  10. # @example
  11. # service = Resumes::SkillAggregationService.new(user)
  12. # service.aggregate_all # Recompute all skills
  13. # service.aggregate_skill(ruby_skill) # Recompute single skill
  14. #
  15. class SkillAggregationService
  16. # Weight multipliers
  17. RECENCY_WEIGHTS = {
  18. current_year: 1.0,
  19. last_year: 0.8,
  20. older: 0.6
  21. }.freeze
  22. PURPOSE_WEIGHTS = {
  23. role_specific: 1.1,
  24. company_specific: 1.0,
  25. generic: 0.9
  26. }.freeze
  27. attr_reader :user
  28. # Initialize the service
  29. #
  30. # @param user [User] The user to aggregate skills for
  31. def initialize(user)
  32. @user = user
  33. end
  34. # Aggregates all skills from all resumes
  35. #
  36. # @return [Array<UserSkill>] Updated user skills
  37. def aggregate_all
  38. # Get all unique skill_tag_ids from user's resumes
  39. skill_tag_ids = ResumeSkill
  40. .joins(:user_resume)
  41. .where(user_resumes: { user_id: user.id })
  42. .distinct
  43. .pluck(:skill_tag_id)
  44. skill_tag_ids.map do |skill_tag_id|
  45. skill_tag = SkillTag.find(skill_tag_id)
  46. aggregate_skill(skill_tag)
  47. end.compact
  48. end
  49. # Aggregates a single skill across all resumes
  50. #
  51. # @param skill_tag [SkillTag] The skill to aggregate
  52. # @return [UserSkill, nil] The updated user skill or nil if no data
  53. def aggregate_skill(skill_tag)
  54. resume_skills = fetch_resume_skills(skill_tag)
  55. if resume_skills.empty?
  56. # Remove user skill if no resume skills exist
  57. user.user_skills.find_by(skill_tag: skill_tag)&.destroy
  58. return nil
  59. end
  60. latest_by_resume_id = latest_demonstrated_dates_by_resume_id(skill_tag)
  61. aggregated_data = compute_aggregated_data(resume_skills, latest_by_resume_id: latest_by_resume_id)
  62. user_skill = user.user_skills.find_or_initialize_by(skill_tag: skill_tag)
  63. user_skill.update!(
  64. aggregated_level: aggregated_data[:level],
  65. confidence_score: aggregated_data[:confidence],
  66. category: aggregated_data[:category],
  67. resume_count: resume_skills.size,
  68. max_years_experience: aggregated_data[:max_years],
  69. last_demonstrated_at: aggregated_data[:last_demonstrated_at]
  70. )
  71. user_skill
  72. end
  73. # Removes skills that no longer have any resume_skills
  74. #
  75. # @return [Integer] Number of skills removed
  76. def cleanup_orphaned_skills
  77. orphaned = user.user_skills.left_outer_joins(:skill_tag)
  78. .joins("LEFT OUTER JOIN resume_skills ON resume_skills.skill_tag_id = user_skills.skill_tag_id
  79. AND resume_skills.user_resume_id IN (SELECT id FROM user_resumes WHERE user_id = #{user.id})")
  80. .where(resume_skills: { id: nil })
  81. count = orphaned.count
  82. orphaned.destroy_all
  83. count
  84. end
  85. private
  86. # Fetches all resume skills for a given skill tag
  87. #
  88. # @param skill_tag [SkillTag] The skill tag
  89. # @return [Array<ResumeSkill>] Resume skills with resume data
  90. def fetch_resume_skills(skill_tag)
  91. ResumeSkill
  92. .includes(:user_resume)
  93. .joins(:user_resume)
  94. .where(skill_tag: skill_tag, user_resumes: { user_id: user.id })
  95. .to_a
  96. end
  97. # Computes aggregated data from resume skills
  98. #
  99. # @param resume_skills [Array<ResumeSkill>] Resume skills to aggregate
  100. # @return [Hash] Aggregated data
  101. def compute_aggregated_data(resume_skills, latest_by_resume_id:)
  102. weighted_levels = []
  103. weighted_confidences = []
  104. categories = Hash.new(0)
  105. max_years = nil
  106. last_demonstrated = nil
  107. resume_skills.each do |rs|
  108. resume = rs.user_resume
  109. weight = calculate_weight(resume)
  110. # Use user_level if set, otherwise model_level
  111. level = rs.effective_level
  112. confidence = rs.confidence_score || 0.5
  113. weighted_levels << { value: level, weight: weight }
  114. weighted_confidences << { value: confidence, weight: weight }
  115. categories[rs.category] += weight if rs.category.present?
  116. # Track max years
  117. max_years = [ max_years || 0, rs.years_of_experience || 0 ].max
  118. # Track most recent demonstrated date for this skill (prefer work experience dates).
  119. demonstrated_on =
  120. latest_by_resume_id[resume.id] ||
  121. resume.resume_updated_at ||
  122. resume.created_at&.to_date
  123. last_demonstrated = [ last_demonstrated, demonstrated_on ].compact.max
  124. end
  125. {
  126. level: weighted_average(weighted_levels),
  127. confidence: weighted_average(weighted_confidences),
  128. category: categories.max_by { |_, v| v }&.first || "Other",
  129. max_years: max_years.positive? ? max_years : nil,
  130. last_demonstrated_at: last_demonstrated&.to_time&.in_time_zone
  131. }
  132. end
  133. # Builds a map of user_resume_id => latest demonstrated Date for a given skill_tag,
  134. # based on extracted work experience dates (best-effort).
  135. #
  136. # @param skill_tag [SkillTag]
  137. # @return [Hash{Integer => Date}]
  138. def latest_demonstrated_dates_by_resume_id(skill_tag)
  139. rows = ResumeWorkExperienceSkill
  140. .joins(resume_work_experience: :user_resume)
  141. .where(skill_tag_id: skill_tag.id, user_resumes: { user_id: user.id })
  142. .group("resume_work_experiences.user_resume_id")
  143. .pluck(
  144. Arel.sql("resume_work_experiences.user_resume_id"),
  145. Arel.sql("MAX(CASE WHEN resume_work_experiences.current THEN CURRENT_DATE ELSE COALESCE(resume_work_experiences.end_date, resume_work_experiences.start_date) END)")
  146. )
  147. rows.to_h
  148. rescue StandardError
  149. {}
  150. end
  151. # Calculates weight for a resume based on recency and purpose
  152. #
  153. # @param resume [UserResume] The resume
  154. # @return [Float] Weight multiplier
  155. def calculate_weight(resume)
  156. recency_weight = calculate_recency_weight(resume.created_at)
  157. purpose_weight = PURPOSE_WEIGHTS[resume.purpose.to_sym] || 1.0
  158. recency_weight * purpose_weight
  159. end
  160. # Calculates recency weight based on resume age
  161. #
  162. # @param created_at [DateTime] Resume creation date
  163. # @return [Float] Recency weight
  164. def calculate_recency_weight(created_at)
  165. age_in_years = (Time.current - created_at) / 1.year
  166. if age_in_years < 1
  167. RECENCY_WEIGHTS[:current_year]
  168. elsif age_in_years < 2
  169. RECENCY_WEIGHTS[:last_year]
  170. else
  171. RECENCY_WEIGHTS[:older]
  172. end
  173. end
  174. # Computes weighted average
  175. #
  176. # @param items [Array<Hash>] Array of {value:, weight:} hashes
  177. # @return [Float] Weighted average
  178. def weighted_average(items)
  179. return 0.0 if items.empty?
  180. total_weight = items.sum { |i| i[:weight] }
  181. return 0.0 if total_weight.zero?
  182. weighted_sum = items.sum { |i| i[:value] * i[:weight] }
  183. (weighted_sum / total_weight).round(2)
  184. end
  185. end
  186. end

app/services/resumes/text_extractor_service.rb

0.0% lines covered

100.0% branches covered

113 relevant lines. 0 lines covered and 113 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Resumes
  3. # Service for extracting text content from uploaded resume files
  4. #
  5. # Supports PDF, DOCX, DOC, and plain text files.
  6. #
  7. # @example
  8. # service = Resumes::TextExtractorService.new(user_resume)
  9. # result = service.extract
  10. # if result[:success]
  11. # puts result[:text]
  12. # end
  13. #
  14. class TextExtractorService
  15. # Maximum text length to prevent memory issues
  16. MAX_TEXT_LENGTH = 500_000
  17. attr_reader :user_resume
  18. # Initialize the service
  19. #
  20. # @param user_resume [UserResume] The resume to extract text from
  21. def initialize(user_resume)
  22. @user_resume = user_resume
  23. end
  24. # Extracts text content from the resume file
  25. #
  26. # @return [Hash] Result with :success, :text, :error keys
  27. def extract
  28. return error_result("No file attached") unless user_resume.file.attached?
  29. text = case user_resume.file_extension
  30. when "pdf"
  31. extract_from_pdf
  32. when "docx"
  33. extract_from_docx
  34. when "doc"
  35. extract_from_doc
  36. when "txt"
  37. extract_from_text
  38. else
  39. return error_result("Unsupported file type: #{user_resume.file_extension}")
  40. end
  41. return error_result("No text content extracted") if text.blank?
  42. # Truncate if too long
  43. text = text.truncate(MAX_TEXT_LENGTH) if text.length > MAX_TEXT_LENGTH
  44. # Store the parsed text
  45. user_resume.update!(parsed_text: text)
  46. success_result(text)
  47. rescue PDF::Reader::MalformedPDFError => e
  48. error_result("Invalid or corrupted PDF file: #{e.message}")
  49. rescue Docx::Errors::DocxError => e
  50. error_result("Invalid or corrupted Word document: #{e.message}")
  51. rescue StandardError => e
  52. Rails.logger.error("Text extraction failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
  53. error_result("Failed to extract text: #{e.message}")
  54. end
  55. private
  56. # Extracts text from PDF files
  57. #
  58. # @return [String] Extracted text
  59. def extract_from_pdf
  60. download_and_process do |tempfile|
  61. reader = PDF::Reader.new(tempfile.path)
  62. pages_text = reader.pages.map do |page|
  63. page.text
  64. rescue StandardError => e
  65. Rails.logger.warn("Failed to extract text from PDF page: #{e.message}")
  66. ""
  67. end
  68. pages_text.join("\n\n")
  69. end
  70. end
  71. # Extracts text from DOCX files
  72. #
  73. # @return [String] Extracted text
  74. def extract_from_docx
  75. download_and_process do |tempfile|
  76. doc = Docx::Document.open(tempfile.path)
  77. paragraphs = doc.paragraphs.map(&:text)
  78. paragraphs.join("\n\n")
  79. end
  80. end
  81. # Extracts text from DOC files (legacy Word format)
  82. # Falls back to antiword or catdoc if available, otherwise tries basic extraction
  83. #
  84. # @return [String] Extracted text
  85. def extract_from_doc
  86. download_and_process do |tempfile|
  87. # Try antiword first (common on Linux)
  88. if system("which antiword > /dev/null 2>&1")
  89. `antiword #{Shellwords.escape(tempfile.path)} 2>/dev/null`
  90. # Try catdoc as fallback
  91. elsif system("which catdoc > /dev/null 2>&1")
  92. `catdoc #{Shellwords.escape(tempfile.path)} 2>/dev/null`
  93. else
  94. # Basic fallback - try to read as text with encoding handling
  95. content = File.read(tempfile.path, encoding: "ISO-8859-1")
  96. # Extract readable text portions
  97. content.encode("UTF-8", invalid: :replace, undef: :replace)
  98. .gsub(/[^\x20-\x7E\n\r\t]/, " ")
  99. .gsub(/\s+/, " ")
  100. .strip
  101. end
  102. end
  103. end
  104. # Extracts text from plain text files
  105. #
  106. # @return [String] Extracted text
  107. def extract_from_text
  108. download_and_process do |tempfile|
  109. File.read(tempfile.path, encoding: "UTF-8")
  110. rescue Encoding::InvalidByteSequenceError
  111. File.read(tempfile.path, encoding: "ISO-8859-1").encode("UTF-8")
  112. end
  113. end
  114. # Downloads the file to a tempfile and yields it for processing
  115. #
  116. # @yield [Tempfile] The downloaded file
  117. # @return [String] Result from the block
  118. def download_and_process
  119. extension = ".#{user_resume.file_extension}"
  120. tempfile = Tempfile.new([ "resume", extension ])
  121. tempfile.binmode
  122. begin
  123. user_resume.file.download { |chunk| tempfile.write(chunk) }
  124. tempfile.rewind
  125. yield tempfile
  126. ensure
  127. tempfile.close
  128. tempfile.unlink
  129. end
  130. end
  131. # Builds a success result hash
  132. #
  133. # @param text [String] Extracted text
  134. # @return [Hash]
  135. def success_result(text)
  136. {
  137. success: true,
  138. text: clean_text(text),
  139. char_count: text.length,
  140. word_count: text.split(/\s+/).count
  141. }
  142. end
  143. # Builds an error result hash
  144. #
  145. # @param message [String] Error message
  146. # @return [Hash]
  147. def error_result(message)
  148. {
  149. success: false,
  150. error: message
  151. }
  152. end
  153. # Cleans extracted text for better AI processing
  154. #
  155. # @param text [String] Raw text
  156. # @return [String] Cleaned text
  157. def clean_text(text)
  158. text
  159. .gsub(/\r\n/, "\n") # Normalize line endings
  160. .gsub(/\r/, "\n")
  161. .gsub(/\n{3,}/, "\n\n") # Collapse multiple newlines
  162. .gsub(/[ \t]+/, " ") # Collapse multiple spaces
  163. .gsub(/^\s+$/, "") # Remove whitespace-only lines
  164. .strip
  165. end
  166. end
  167. end

app/services/resumes/work_history_aggregation_service.rb

0.0% lines covered

100.0% branches covered

80 relevant lines. 0 lines covered and 80 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Resumes
  3. # Aggregates work history across all analyzed resumes into a merged user-level profile.
  4. class WorkHistoryAggregationService
  5. # @param user [User]
  6. def initialize(user)
  7. @user = user
  8. end
  9. # @return [void]
  10. def run
  11. ActiveRecord::Base.transaction do
  12. rebuild_user_work_experiences!
  13. end
  14. end
  15. private
  16. attr_reader :user
  17. def rebuild_user_work_experiences!
  18. # Clear and rebuild for correctness (can be optimized later).
  19. UserWorkExperience.where(user: user).destroy_all
  20. resume_experiences = ResumeWorkExperience
  21. .joins(:user_resume)
  22. .includes(:company, :job_role, :resume_work_experience_skills, :skill_tags)
  23. .where(user_resumes: { user_id: user.id, analysis_status: UserResume.analysis_statuses[:completed] })
  24. .to_a
  25. groups = resume_experiences.group_by { |rwe| merge_key_for(rwe) }
  26. groups.each do |key, items|
  27. next if key.blank?
  28. merged = build_merged_experience(items)
  29. uwe = UserWorkExperience.create!(merged.merge(user: user))
  30. items.each do |rwe|
  31. UserWorkExperienceSource.create!(user_work_experience: uwe, resume_work_experience: rwe)
  32. end
  33. upsert_experience_skills!(uwe, items)
  34. end
  35. end
  36. def merge_key_for(rwe)
  37. company = rwe.display_company_name.to_s.strip.downcase
  38. role = rwe.display_role_title.to_s.strip.downcase
  39. [ company.presence, role.presence ].compact.join("|")
  40. end
  41. def build_merged_experience(items)
  42. first = items.first
  43. company_name = items.map(&:display_company_name).map { |s| s.to_s.strip }.reject(&:blank?).first
  44. role_title = items.map(&:display_role_title).map { |s| s.to_s.strip }.reject(&:blank?).first
  45. start_date = items.map(&:start_date).compact.min
  46. end_date = items.map(&:end_date).compact.max
  47. current = items.any?(&:current)
  48. responsibilities = items.flat_map { |i| Array(i.responsibilities) }.map { |s| s.to_s.strip }.reject(&:blank?).uniq.first(50)
  49. highlights = items.flat_map { |i| Array(i.highlights) }.map { |s| s.to_s.strip }.reject(&:blank?).uniq.first(50)
  50. {
  51. company: first.company,
  52. job_role: first.job_role,
  53. company_name: company_name,
  54. role_title: role_title,
  55. start_date: start_date,
  56. end_date: end_date,
  57. current: current,
  58. responsibilities: responsibilities,
  59. highlights: highlights,
  60. source_count: items.size,
  61. merge_keys: { merge_key: merge_key_for(first) }
  62. }
  63. end
  64. def upsert_experience_skills!(user_work_experience, resume_items)
  65. # Build counts across source experiences.
  66. skills = Hash.new { |h, k| h[k] = { count: 0, last_used_on: nil } }
  67. resume_items.each do |rwe|
  68. last_used_on = rwe.end_date || rwe.start_date
  69. last_used_on = Date.current if rwe.current
  70. rwe.resume_work_experience_skills.each do |row|
  71. entry = skills[row.skill_tag_id]
  72. entry[:count] += 1
  73. entry[:last_used_on] = [ entry[:last_used_on], last_used_on ].compact.max
  74. end
  75. end
  76. skills.each do |skill_tag_id, data|
  77. UserWorkExperienceSkill.create!(
  78. user_work_experience: user_work_experience,
  79. skill_tag_id: skill_tag_id,
  80. source_count: data[:count],
  81. last_used_on: data[:last_used_on]
  82. )
  83. end
  84. end
  85. end
  86. end

app/services/scraping/ai_job_extractor_service.rb

0.0% lines covered

100.0% branches covered

184 relevant lines. 0 lines covered and 184 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Service for AI-powered job listing extraction
  4. #
  5. # Uses configured LLM providers to extract structured data from HTML,
  6. # with automatic fallback to alternative providers on failure.
  7. # Supports idempotent retries by accepting pre-fetched HTML content.
  8. #
  9. # @example
  10. # extractor = Scraping::AiJobExtractorService.new(job_listing, scraping_attempt)
  11. # result = extractor.extract(html_content: cached_html)
  12. # if result[:confidence] >= 0.7
  13. # # Use extracted data
  14. # end
  15. class AiJobExtractorService < ApplicationService
  16. include Concerns::Loggable
  17. attr_reader :job_listing, :scraping_attempt, :url
  18. # Initialize the extractor
  19. #
  20. # @param job_listing [JobListing] The job listing
  21. # @param scraping_attempt [ScrapingAttempt, nil] Optional scraping attempt
  22. def initialize(job_listing, scraping_attempt: nil)
  23. @job_listing = job_listing
  24. @scraping_attempt = scraping_attempt
  25. @url = job_listing.url
  26. end
  27. # Extracts job data using AI providers with fallback
  28. #
  29. # @param html_content [String, nil] Pre-fetched HTML content (for idempotent retries)
  30. # @param cleaned_html [String, nil] Pre-cleaned HTML content
  31. # @return [Hash] Extracted job data with confidence score
  32. def extract(html_content: nil, cleaned_html: nil)
  33. log_event("ai_extraction_started")
  34. html_for_extraction = get_html_content(html_content, cleaned_html)
  35. return extraction_error("No HTML content available") unless html_for_extraction.present?
  36. prompt = build_extraction_prompt(html_for_extraction)
  37. extract_with_providers(prompt, html_for_extraction.bytesize)
  38. end
  39. private
  40. def get_html_content(html_content, cleaned_html)
  41. if cleaned_html.present?
  42. cleaned_html
  43. elsif html_content.present?
  44. Scraping::NokogiriHtmlCleanerService.new.clean(html_content)
  45. else
  46. fetch_html_content
  47. end
  48. end
  49. def fetch_html_content
  50. fetch_result = Scraping::HtmlFetcherService.new(@job_listing, scraping_attempt: @scraping_attempt).call
  51. unless fetch_result[:success]
  52. log_event("ai_extraction_failed", { error: fetch_result[:error] || "Failed to fetch HTML" })
  53. return nil
  54. end
  55. fetch_result[:cleaned_html]
  56. end
  57. def extract_with_providers(prompt, html_size)
  58. prompt_template = Ai::JobExtractionPrompt.active_prompt
  59. system_message = prompt_template&.system_prompt.presence || Ai::JobExtractionPrompt.default_system_prompt
  60. runner = Ai::ProviderRunnerService.new(
  61. provider_chain: provider_chain,
  62. prompt: prompt,
  63. content_size: html_size,
  64. system_message: system_message,
  65. provider_for: method(:get_provider_instance),
  66. logger_builder: lambda { |provider_name, provider|
  67. Ai::ApiLoggerService.new(
  68. operation_type: :job_extraction,
  69. loggable: @job_listing,
  70. provider: provider_name,
  71. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  72. llm_prompt: prompt_template
  73. )
  74. },
  75. on_rate_limit: lambda { |response, provider_name, _logger|
  76. handle_rate_limit(provider_name, response)
  77. },
  78. on_error: lambda { |response, provider_name, _logger|
  79. log_event("ai_extraction_failed", { provider: provider_name, error: response[:error] })
  80. },
  81. operation: :job_extraction,
  82. loggable: @job_listing,
  83. user: job_listing_user,
  84. error_context: {
  85. severity: "warning",
  86. job_listing_id: @job_listing&.id,
  87. url: @url
  88. }
  89. )
  90. result = runner.run do |response|
  91. parsed = parse_response(response[:content])
  92. log_data = (parsed || {}).merge(
  93. confidence: parsed&.dig(:confidence),
  94. model: response[:model]
  95. )
  96. [ parsed, log_data, true ]
  97. end
  98. return extraction_error("All providers failed or returned low confidence") unless result[:success]
  99. confidence = result[:parsed][:confidence] || 0.0
  100. if confidence >= 0.7
  101. log_event("ai_extraction_succeeded", { provider: result[:provider], confidence: confidence })
  102. else
  103. log_event("ai_extraction_low_confidence", { provider: result[:provider], confidence: confidence })
  104. end
  105. build_extraction_result(result[:parsed], result[:result].merge(model: result[:model]), result[:provider]).merge(
  106. prompt_used: prompt
  107. )
  108. end
  109. def job_listing_user
  110. @job_listing.interview_applications.order(created_at: :desc).first&.user
  111. end
  112. def handle_rate_limit(provider_name, result)
  113. log_event("ai_extraction_rate_limited", {
  114. provider: provider_name,
  115. retry_after: result[:retry_after]
  116. })
  117. if result[:retry_after] && result[:retry_after] > 0
  118. wait_time = [ result[:retry_after], 60 ].min
  119. sleep(wait_time)
  120. end
  121. end
  122. def build_extraction_result(parsed, result, provider_name)
  123. parsed.merge(
  124. provider: provider_name,
  125. model: result[:model],
  126. input_tokens: result[:input_tokens],
  127. output_tokens: result[:output_tokens],
  128. raw_response: result[:content] || result[:raw_response]
  129. )
  130. end
  131. def extraction_error(message)
  132. log_event("ai_extraction_failed", { error: message })
  133. { error: message, confidence: 0.0 }
  134. end
  135. # Prompt building
  136. def build_extraction_prompt(html_content)
  137. prompt_template = Ai::JobExtractionPrompt.active_prompt
  138. if prompt_template && prompt_supports_company_sections?(prompt_template.prompt_template)
  139. prompt_template.build_prompt(url: @url, html_content: html_content)
  140. else
  141. Ai::JobExtractionPrompt.default_prompt_template
  142. .gsub("{{url}}", @url)
  143. .gsub("{{html_content}}", html_content)
  144. end
  145. end
  146. def prompt_supports_company_sections?(template)
  147. return false unless template.is_a?(String)
  148. template.include?("about_company") && template.include?("company_culture")
  149. end
  150. # Response parsing
  151. def parse_response(response_text)
  152. return { error: "No response", confidence: 0.0 } unless response_text.present?
  153. json_match = response_text.match(/\{.*\}/m)
  154. return { error: "No JSON found in response", confidence: 0.0 } unless json_match
  155. data = JSON.parse(json_match[0])
  156. normalize_parsed_data(data)
  157. rescue JSON::ParserError => e
  158. Rails.logger.error("Failed to parse LLM response: #{e.message}")
  159. { error: "Invalid JSON response", confidence: 0.0 }
  160. end
  161. def normalize_parsed_data(data)
  162. # Build custom_sections with markdown and additional extracted fields
  163. custom_sections = data["custom_sections"] || {}
  164. custom_sections["description_markdown"] = data["description_markdown"] if data["description_markdown"].present?
  165. custom_sections["compensation_text"] = data["compensation_text"] if data["compensation_text"].present?
  166. custom_sections["interview_process"] = data["interview_process"] if data["interview_process"].present?
  167. {
  168. title: data["title"],
  169. company: data["company"] || data["company_name"],
  170. job_role: data["job_role"] || data["job_role_title"],
  171. job_role_department: data["job_role_department"],
  172. job_board: data["job_board"],
  173. description: data["description"],
  174. description_markdown: data["description_markdown"],
  175. about_company: data["about_company"],
  176. company_culture: data["company_culture"],
  177. requirements: data["requirements"],
  178. responsibilities: data["responsibilities"],
  179. location: data["location"],
  180. remote_type: data["remote_type"],
  181. salary_min: data["salary_min"],
  182. salary_max: data["salary_max"],
  183. salary_currency: data["salary_currency"] || "USD",
  184. compensation_text: data["compensation_text"],
  185. equity_info: data["equity_info"],
  186. benefits: data["benefits"],
  187. perks: data["perks"],
  188. interview_process: data["interview_process"],
  189. custom_sections: custom_sections,
  190. confidence: data["confidence_score"] || 0.5,
  191. notes: data["notes"]
  192. }
  193. end
  194. # Provider management
  195. def provider_chain
  196. LlmProviders::ProviderConfigHelper.all_providers
  197. end
  198. def get_provider_instance(provider_name)
  199. provider = case provider_name.to_s.downcase
  200. when "openai" then LlmProviders::OpenaiProvider.new
  201. when "anthropic" then LlmProviders::AnthropicProvider.new
  202. when "ollama" then LlmProviders::OllamaProvider.new
  203. else raise ArgumentError, "Unknown provider: #{provider_name}"
  204. end
  205. provider.scraping_attempt = @scraping_attempt
  206. provider.job_listing = @job_listing
  207. provider
  208. end
  209. end
  210. end

app/services/scraping/ai_job_post_processor_service.rb

0.0% lines covered

100.0% branches covered

110 relevant lines. 0 lines covered and 110 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # LLM-based post-processor for job content.
  4. #
  5. # Used after a high-confidence source (e.g., Greenhouse boards API) fetch to:
  6. # - Extract missing structured fields (compensation, interview process, etc.)
  7. # - Produce a clean Markdown version for display
  8. #
  9. # This is deliberately "best effort" and should not fail the scrape.
  10. class AiJobPostProcessorService < ApplicationService
  11. attr_reader :job_listing, :scraping_attempt
  12. # @param job_listing [JobListing]
  13. # @param scraping_attempt [ScrapingAttempt, nil]
  14. # @param providers [Array<String>, nil] Optional provider chain override
  15. def initialize(job_listing, scraping_attempt: nil, providers: nil)
  16. @job_listing = job_listing
  17. @scraping_attempt = scraping_attempt
  18. @providers = providers
  19. end
  20. # @param content_html [String]
  21. # @param url [String]
  22. # @return [Hash] normalized result hash (best effort)
  23. def run(content_html:, url:)
  24. return { error: "No content", confidence: 0.0 } if content_html.blank?
  25. prompt_template = Ai::JobPostprocessPrompt.active_prompt
  26. prompt = if prompt_template
  27. prompt_template.build_prompt(url: url, html_content: content_html)
  28. else
  29. Ai::JobPostprocessPrompt.default_prompt_template
  30. .gsub("{{url}}", url)
  31. .gsub("{{html_content}}", content_html)
  32. end
  33. system_message = prompt_template&.system_prompt.presence || Ai::JobPostprocessPrompt.default_system_prompt
  34. runner = Ai::ProviderRunnerService.new(
  35. provider_chain: provider_chain,
  36. prompt: prompt,
  37. content_size: content_html.bytesize,
  38. system_message: system_message,
  39. provider_for: method(:get_provider_instance),
  40. logger_builder: lambda { |name, provider_instance|
  41. Ai::ApiLoggerService.new(
  42. operation_type: :job_postprocess,
  43. loggable: job_listing,
  44. provider: name,
  45. model: provider_instance.respond_to?(:model_name) ? provider_instance.model_name : "unknown",
  46. llm_prompt: prompt_template
  47. )
  48. },
  49. operation: :job_postprocess,
  50. loggable: job_listing,
  51. user: job_listing&.user,
  52. error_context: {
  53. severity: "warning",
  54. job_listing_id: job_listing&.id,
  55. scraping_attempt_id: scraping_attempt&.id,
  56. url: url
  57. }
  58. )
  59. result = runner.run do |response|
  60. parsed = parse_json_result(response[:content])
  61. confidence = parsed[:confidence].to_f
  62. log_data = parsed.merge(
  63. content: response[:content],
  64. confidence: confidence,
  65. error: response[:error],
  66. error_type: response[:error_type]
  67. )
  68. accept = confidence > 0.0
  69. [ parsed, log_data, accept ]
  70. end
  71. return result[:parsed] if result[:success]
  72. { error: "No provider available", confidence: 0.0 }
  73. rescue => e
  74. notify_ai_error(
  75. e,
  76. operation: "job_postprocess",
  77. loggable: job_listing,
  78. severity: "warning",
  79. job_listing_id: job_listing.id,
  80. scraping_attempt_id: scraping_attempt&.id,
  81. url: url
  82. )
  83. { error: e.message, confidence: 0.0 }
  84. end
  85. private
  86. def parse_json_result(text)
  87. return { error: "No response", confidence: 0.0 } if text.blank?
  88. data = Ai::ResponseParserService.new(text).parse
  89. return { error: "No JSON found", confidence: 0.0 } unless data
  90. normalize_parsed_data(data)
  91. rescue JSON::ParserError => e
  92. { error: "Invalid JSON: #{e.message}", confidence: 0.0 }
  93. end
  94. def normalize_parsed_data(data)
  95. {
  96. job_markdown: data["job_markdown"].to_s,
  97. compensation_text: data["compensation_text"],
  98. salary_min: data["salary_min"],
  99. salary_max: data["salary_max"],
  100. salary_currency: data["salary_currency"],
  101. interview_process: data["interview_process"],
  102. responsibilities_bullets: Array(data["responsibilities_bullets"]).map(&:to_s),
  103. requirements_bullets: Array(data["requirements_bullets"]).map(&:to_s),
  104. benefits_bullets: Array(data["benefits_bullets"]).map(&:to_s),
  105. perks_bullets: Array(data["perks_bullets"]).map(&:to_s),
  106. confidence: data["confidence_score"].to_f
  107. }
  108. end
  109. def provider_chain
  110. @providers.presence || LlmProviders::ProviderConfigHelper.all_providers
  111. end
  112. def get_provider_instance(provider_name)
  113. provider = case provider_name.to_s.downcase
  114. when "openai" then LlmProviders::OpenaiProvider.new
  115. when "anthropic" then LlmProviders::AnthropicProvider.new
  116. when "ollama" then LlmProviders::OllamaProvider.new
  117. else raise ArgumentError, "Unknown provider: #{provider_name}"
  118. end
  119. provider.scraping_attempt = scraping_attempt
  120. provider.job_listing = job_listing
  121. provider
  122. end
  123. end
  124. end

app/services/scraping/anthropic_rate_limiter_service.rb

0.0% lines covered

100.0% branches covered

52 relevant lines. 0 lines covered and 52 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Service for tracking and limiting Anthropic API token usage
  4. #
  5. # Implements a rolling window rate limiter to ensure we don't exceed
  6. # Anthropic's 30,000 input tokens per minute limit.
  7. #
  8. # @example
  9. # limiter = Scraping::AnthropicRateLimiterService.new
  10. # if limiter.can_send_tokens?(estimated_tokens)
  11. # # Make request
  12. # limiter.record_tokens_used(actual_tokens)
  13. # else
  14. # wait_time = limiter.wait_time_for_tokens(estimated_tokens)
  15. # sleep(wait_time)
  16. # end
  17. class AnthropicRateLimiterService
  18. TOKEN_LIMIT_PER_MINUTE = 30_000
  19. WINDOW_SECONDS = 60
  20. CACHE_KEY = "anthropic_token_usage"
  21. CACHE_EXPIRATION = 2.minutes # Longer than window to handle edge cases
  22. # Checks if a request with estimated tokens can be sent
  23. #
  24. # @param [Integer] estimated_tokens Estimated token count for the request
  25. # @return [Boolean] True if request can be sent
  26. def can_send_tokens?(estimated_tokens)
  27. current_usage = total_usage_in_window
  28. (current_usage + estimated_tokens) <= TOKEN_LIMIT_PER_MINUTE
  29. end
  30. # Calculates wait time needed before sending tokens
  31. #
  32. # @param [Integer] estimated_tokens Estimated token count
  33. # @return [Float] Seconds to wait (0 if can send immediately)
  34. def wait_time_for_tokens(estimated_tokens)
  35. return 0.0 if can_send_tokens?(estimated_tokens)
  36. current_usage = total_usage_in_window
  37. tokens_needed = estimated_tokens
  38. tokens_available = TOKEN_LIMIT_PER_MINUTE - current_usage
  39. if tokens_available < tokens_needed
  40. # Need to wait for window to roll over
  41. oldest_timestamp = oldest_request_time
  42. return 0.0 if oldest_timestamp.nil?
  43. elapsed = Time.current - oldest_timestamp
  44. remaining = WINDOW_SECONDS - elapsed
  45. [ remaining.ceil, 0 ].max.to_f
  46. else
  47. 0.0
  48. end
  49. end
  50. # Records token usage for a request
  51. #
  52. # @param [Integer] tokens Actual tokens used
  53. # @return [void]
  54. def record_tokens_used(tokens)
  55. return if tokens.nil? || tokens <= 0
  56. usage = current_usage_array
  57. usage << { timestamp: Time.current, tokens: tokens }
  58. # Clean up old entries (older than 1 minute)
  59. cleanup_old_entries(usage)
  60. # Store back to cache
  61. Rails.cache.write(CACHE_KEY, usage, expires_in: CACHE_EXPIRATION)
  62. end
  63. # Gets current total token usage in the rolling window
  64. #
  65. # @return [Integer] Total tokens used in last 60 seconds
  66. def total_usage_in_window
  67. usage = current_usage_array
  68. cleanup_old_entries(usage)
  69. usage.sum { |entry| entry[:tokens] }
  70. end
  71. private
  72. # Gets the current usage array from cache
  73. #
  74. # @return [Array<Hash>] Array of {timestamp, tokens} entries
  75. def current_usage_array
  76. Rails.cache.read(CACHE_KEY) || []
  77. end
  78. # Removes entries older than the window
  79. #
  80. # @param [Array<Hash>] usage The usage array (modified in place)
  81. # @return [void]
  82. def cleanup_old_entries(usage)
  83. cutoff = Time.current - WINDOW_SECONDS.seconds
  84. usage.reject! { |entry| entry[:timestamp] < cutoff }
  85. end
  86. # Gets the timestamp of the oldest request in the window
  87. #
  88. # @return [Time, nil] Oldest timestamp or nil
  89. def oldest_request_time
  90. usage = current_usage_array
  91. return nil if usage.empty?
  92. usage.map { |e| e[:timestamp] }.min
  93. end
  94. end
  95. end

app/services/scraping/concerns/loggable.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Concerns
  4. # Concern for consistent structured logging across scraping services
  5. #
  6. # Provides standardized logging methods that include service context
  7. # and job listing information for better traceability.
  8. #
  9. # @example
  10. # class MyService
  11. # include Scraping::Concerns::Loggable
  12. #
  13. # def initialize(job_listing, scraping_attempt: nil)
  14. # @job_listing = job_listing
  15. # @scraping_attempt = scraping_attempt
  16. # @url = job_listing.url
  17. # end
  18. #
  19. # def call
  20. # log_event("operation_started")
  21. # # ... do work
  22. # log_event("operation_completed", { result: "success" })
  23. # end
  24. # end
  25. module Loggable
  26. # Logs a structured event
  27. #
  28. # @param [String] event_name The event name
  29. # @param [Hash] data Additional event data
  30. def log_event(event_name, data = {})
  31. base_data = {
  32. event: event_name,
  33. service: self.class.name,
  34. job_listing_id: @job_listing&.id,
  35. scraping_attempt_id: @scraping_attempt&.id,
  36. url: @url || @job_listing&.url
  37. }
  38. Rails.logger.info(base_data.merge(data).to_json)
  39. end
  40. # Logs an error with optional exception details
  41. #
  42. # @param [String] message The error message
  43. # @param [Exception, nil] exception Optional exception object
  44. def log_error(message, exception = nil)
  45. error_data = {
  46. error: message,
  47. service: self.class.name,
  48. job_listing_id: @job_listing&.id,
  49. scraping_attempt_id: @scraping_attempt&.id,
  50. url: @url || @job_listing&.url
  51. }
  52. if exception
  53. error_data.merge!(
  54. exception: exception.class.name,
  55. message: exception.message,
  56. backtrace: exception.backtrace&.first(5)
  57. )
  58. end
  59. Rails.logger.error(error_data.to_json)
  60. end
  61. end
  62. end
  63. end

app/services/scraping/event_recorder_service.rb

0.0% lines covered

100.0% branches covered

148 relevant lines. 0 lines covered and 148 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Service for recording scraping pipeline events
  4. #
  5. # Wraps each step of the extraction process to capture timing, payloads,
  6. # and status for complete observability.
  7. #
  8. # @example
  9. # recorder = Scraping::EventRecorderService.new(attempt)
  10. # result = recorder.record(:html_fetch, step: 1, input: { url: url }) do |event|
  11. # html = fetch_html(url)
  12. # event.set_output(html_size: html.bytesize, http_status: 200)
  13. # html
  14. # end
  15. class EventRecorderService
  16. attr_reader :scraping_attempt, :job_listing, :current_step
  17. # Initialize the recorder with a scraping attempt
  18. #
  19. # @param [ScrapingAttempt] scraping_attempt The scraping attempt to record events for
  20. # @param [JobListing, nil] job_listing Optional job listing reference
  21. def initialize(scraping_attempt, job_listing: nil)
  22. @scraping_attempt = scraping_attempt
  23. @job_listing = job_listing || scraping_attempt.job_listing
  24. @current_step = 0
  25. end
  26. # Records an event for a pipeline step
  27. #
  28. # Wraps the block with timing and captures input/output payloads.
  29. # If the block raises an exception, records a failure and re-raises.
  30. #
  31. # @param [Symbol] event_type The type of event (from ScrapingEvent::EVENT_TYPES)
  32. # @param [Integer, nil] step Override step order (auto-increments if nil)
  33. # @param [Hash] input Input payload to record
  34. # @param [Hash] metadata Additional metadata
  35. # @yield [EventContext] Block that performs the step
  36. # @return [Object] Return value from the block
  37. def record(event_type, step: nil, input: {}, metadata: {})
  38. @current_step = step || (@current_step + 1)
  39. event = create_event(event_type, input, metadata)
  40. context = EventContext.new(event)
  41. start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  42. begin
  43. result = yield(context) if block_given?
  44. # Calculate duration
  45. end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  46. duration_ms = ((end_time - start_time) * 1000).round
  47. # Update event with success
  48. event.update!(
  49. status: :success,
  50. completed_at: Time.current,
  51. duration_ms: duration_ms,
  52. output_payload: context.output_data.merge(output_payload_from_result(result))
  53. )
  54. result
  55. rescue => e
  56. # Calculate duration even for failures
  57. end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  58. duration_ms = ((end_time - start_time) * 1000).round
  59. # Update event with failure
  60. event.update!(
  61. status: :failed,
  62. completed_at: Time.current,
  63. duration_ms: duration_ms,
  64. error_type: e.class.name,
  65. error_message: e.message,
  66. output_payload: context.output_data
  67. )
  68. raise
  69. end
  70. end
  71. # Records a simple event without wrapping a block
  72. #
  73. # Useful for recording events that don't have a distinct start/end.
  74. #
  75. # @param [Symbol] event_type The type of event
  76. # @param [Symbol] status The event status (:success, :failed, :skipped)
  77. # @param [Hash] input Input payload
  78. # @param [Hash] output Output payload
  79. # @param [Hash] metadata Additional metadata
  80. # @param [String, nil] error_message Error message if failed
  81. # @param [String, nil] error_type Error type if failed
  82. # @return [ScrapingEvent] The created event
  83. def record_simple(event_type, status:, input: {}, output: {}, metadata: {}, error_message: nil, error_type: nil)
  84. @current_step += 1
  85. ScrapingEvent.create!(
  86. scraping_attempt: @scraping_attempt,
  87. job_listing: @job_listing,
  88. event_type: event_type,
  89. step_order: @current_step,
  90. status: status,
  91. started_at: Time.current,
  92. completed_at: Time.current,
  93. duration_ms: 0,
  94. input_payload: truncate_payload(input),
  95. output_payload: truncate_payload(output),
  96. metadata: metadata,
  97. error_type: error_type,
  98. error_message: error_message
  99. )
  100. end
  101. # Records a skipped step
  102. #
  103. # @param [Symbol] event_type The type of event
  104. # @param [String] reason Why the step was skipped
  105. # @param [Hash] metadata Additional metadata
  106. # @return [ScrapingEvent] The created event
  107. def record_skipped(event_type, reason:, metadata: {})
  108. @current_step += 1
  109. ScrapingEvent.create!(
  110. scraping_attempt: @scraping_attempt,
  111. job_listing: @job_listing,
  112. event_type: event_type,
  113. step_order: @current_step,
  114. status: :skipped,
  115. started_at: Time.current,
  116. completed_at: Time.current,
  117. duration_ms: 0,
  118. input_payload: {},
  119. output_payload: { skipped_reason: reason },
  120. metadata: metadata
  121. )
  122. end
  123. # Records a completion event
  124. #
  125. # @param [Hash] summary Summary of the extraction
  126. # @return [ScrapingEvent] The created event
  127. def record_completion(summary: {})
  128. record_simple(
  129. :completion,
  130. status: :success,
  131. output: summary,
  132. metadata: { total_steps: @current_step }
  133. )
  134. end
  135. # Records a failure event
  136. #
  137. # @param [String] message Failure message
  138. # @param [String, nil] error_type Type of error
  139. # @param [Hash] details Additional failure details
  140. # @return [ScrapingEvent] The created event
  141. def record_failure(message:, error_type: nil, details: {})
  142. record_simple(
  143. :failure,
  144. status: :failed,
  145. output: details,
  146. error_message: message,
  147. error_type: error_type,
  148. metadata: { total_steps: @current_step }
  149. )
  150. end
  151. private
  152. # Creates a new event record
  153. #
  154. # @param [Symbol] event_type The type of event
  155. # @param [Hash] input Input payload
  156. # @param [Hash] metadata Additional metadata
  157. # @return [ScrapingEvent] The created event
  158. def create_event(event_type, input, metadata)
  159. ScrapingEvent.create!(
  160. scraping_attempt: @scraping_attempt,
  161. job_listing: @job_listing,
  162. event_type: event_type,
  163. step_order: @current_step,
  164. status: :started,
  165. started_at: Time.current,
  166. input_payload: truncate_payload(input),
  167. output_payload: {},
  168. metadata: metadata
  169. )
  170. end
  171. # Extracts output payload from a result
  172. #
  173. # @param [Object] result The result to extract from
  174. # @return [Hash] Extracted payload
  175. def output_payload_from_result(result)
  176. return {} unless result.is_a?(Hash)
  177. # Only include relevant keys, not the entire result
  178. safe_keys = %i[
  179. success error confidence html_size http_status
  180. cleaned_html_size content_length text_length
  181. board_type company_slug job_id api_supported
  182. fetch_mode rendered extractor_kind run_context
  183. missing_fields selectors_tried
  184. extracted_fields provider model tokens_used
  185. title company location
  186. ]
  187. result.slice(*safe_keys).transform_values { |v| truncate_value(v) }
  188. end
  189. # Truncates a payload to prevent excessive storage
  190. #
  191. # @param [Hash] payload The payload to truncate
  192. # @return [Hash] Truncated payload
  193. def truncate_payload(payload)
  194. return {} unless payload.is_a?(Hash)
  195. payload.transform_values { |v| truncate_value(v) }
  196. end
  197. # Truncates a single value
  198. #
  199. # @param [Object] value The value to truncate
  200. # @return [Object] Truncated value
  201. def truncate_value(value)
  202. case value
  203. when String
  204. value.length > 10_000 ? "#{value[0...10_000]}... [TRUNCATED]" : value
  205. when Hash
  206. value.transform_values { |v| truncate_value(v) }
  207. when Array
  208. value.first(100).map { |v| truncate_value(v) }
  209. else
  210. value
  211. end
  212. end
  213. # Context object passed to the block for setting output
  214. class EventContext
  215. attr_reader :output_data
  216. def initialize(event)
  217. @event = event
  218. @output_data = {}
  219. end
  220. # Sets output data for the event
  221. #
  222. # @param [Hash] data The output data
  223. def set_output(data)
  224. @output_data.merge!(data)
  225. end
  226. # Adds to output data
  227. #
  228. # @param [Symbol, String] key The key
  229. # @param [Object] value The value
  230. def add_output(key, value)
  231. @output_data[key] = value
  232. end
  233. end
  234. end
  235. end

app/services/scraping/failure_classifier_service.rb

0.0% lines covered

100.0% branches covered

32 relevant lines. 0 lines covered and 32 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Classifies scraping failures into "retryable" vs "terminal/logical".
  4. #
  5. # We only want DLQ + retries for issues that can plausibly succeed on retry:
  6. # - transient network issues (timeouts, 5xx, connection resets)
  7. # - provider outages / Selenium hiccups
  8. # - unexpected exceptions (bugs)
  9. #
  10. # We do NOT want DLQ for "logical" failures like:
  11. # - low confidence extraction
  12. # - thin HTML / rendered shell pages (not enough content)
  13. # - 404/410 (resource missing)
  14. #
  15. # @example
  16. # classifier = Scraping::FailureClassifierService.new(attempt)
  17. # classifier.retryable? #=> true/false
  18. class FailureClassifierService
  19. # @param [ScrapingAttempt] scraping_attempt
  20. def initialize(scraping_attempt)
  21. @attempt = scraping_attempt
  22. end
  23. # @return [Boolean]
  24. def retryable?
  25. return false if logical_low_confidence?
  26. return false if logical_thin_html?
  27. return false if logical_http_not_found?
  28. # If we can’t confidently classify it as logical, default to retryable.
  29. true
  30. rescue
  31. true
  32. end
  33. private
  34. def logical_low_confidence?
  35. return false unless @attempt.failed_step.to_s == "ai_extraction"
  36. @attempt.error_message.to_s.match?(/\Alow confidence:/i) ||
  37. @attempt.error_message.to_s.match?(/extraction failed: low confidence/i)
  38. end
  39. def logical_thin_html?
  40. event = @attempt.scraping_events.where(event_type: :rendered_html_fetch).order(created_at: :desc).first
  41. output = event&.output_payload
  42. return false unless output.is_a?(Hash)
  43. output["rendered_shell"] == true || output["cleaned_text_length"].to_i < 300
  44. end
  45. def logical_http_not_found?
  46. return false unless @attempt.failed_step.to_s == "html_fetch"
  47. msg = @attempt.error_message.to_s
  48. msg.match?(/\AHTTP\s+404:/i) || msg.match?(/\AHTTP\s+410:/i) || msg.match?(/\AHTTP\s+403:/i)
  49. end
  50. end
  51. end

app/services/scraping/html_cleaners/ashby_cleaner.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module HtmlCleaners
  4. # HTML cleaner optimized for Ashby job board pages (jobs.ashbyhq.com)
  5. #
  6. # Ashby uses React with dynamically generated class names like `_title_ud4nd_34`.
  7. # Key structural elements have stable classes like `ashby-job-posting-*`.
  8. class AshbyCleaner < BaseCleaner
  9. def initialize
  10. super(board_type: :ashbyhq)
  11. end
  12. protected
  13. # Ashby-specific elements to remove
  14. def elements_to_remove
  15. super + [
  16. # Ashby navigation and back button
  17. ".ashby-job-board-back-to-all-jobs-button",
  18. "[class*='_navRoot_']",
  19. "[class*='_navContainer_']",
  20. # Tab navigation (Overview/Application tabs)
  21. "[role='tablist']",
  22. "[class*='_tabs_']",
  23. # Application form (we just want the job description)
  24. "[class*='_applicationForm_']",
  25. "form",
  26. # Social sharing
  27. "[class*='share']", "[class*='social']"
  28. ]
  29. end
  30. # Ashby-specific content selectors in priority order
  31. def main_content_selectors
  32. [
  33. # Primary content area (job description)
  34. ".ashby-job-posting-right-pane",
  35. # Container with all job details
  36. "[class*='_details_']",
  37. "[class*='_content_']",
  38. # Left pane has metadata (location, type, etc.)
  39. ".ashby-job-posting-left-pane",
  40. # React root as fallback
  41. "#root",
  42. # Generic fallbacks
  43. "body"
  44. ]
  45. end
  46. # Preserve job-related content even if it matches removal patterns
  47. def elements_to_preserve
  48. [
  49. ".ashby-job-posting-heading",
  50. ".ashby-job-posting-right-pane",
  51. ".ashby-job-posting-left-pane",
  52. "[class*='_section_']"
  53. ]
  54. end
  55. end
  56. end
  57. end

app/services/scraping/html_cleaners/base_cleaner.rb

0.0% lines covered

100.0% branches covered

84 relevant lines. 0 lines covered and 84 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "nokogiri"
  3. module Scraping
  4. module HtmlCleaners
  5. # Base HTML cleaner for extracting main content from job board pages.
  6. #
  7. # Subclasses can override selectors and behavior for specific job boards.
  8. # This provides optimized content extraction for LLM processing.
  9. class BaseCleaner
  10. MAX_TOKENS = 25_000
  11. CHARS_PER_TOKEN = 3
  12. MIN_CONTENT_LENGTH = 100
  13. def initialize(board_type: :unknown)
  14. @board_type = board_type
  15. end
  16. # Cleans HTML content and returns optimized text for LLM
  17. #
  18. # @param html_content [String] Raw HTML content
  19. # @return [String] Cleaned text content
  20. def clean(html_content)
  21. return "" if html_content.blank?
  22. doc = Nokogiri::HTML(html_content)
  23. remove_unwanted_elements(doc)
  24. main_content = extract_main_content(doc)
  25. text = main_content.text
  26. text = normalize_whitespace(text)
  27. truncate_to_token_limit(text, MAX_TOKENS)
  28. end
  29. protected
  30. # Elements to remove (scripts, styles, navigation, etc.)
  31. #
  32. # @return [Array<String>] CSS selectors to remove
  33. def elements_to_remove
  34. [
  35. "script", "style", "noscript",
  36. "nav", "header", "footer",
  37. "[class*='cookie']", "[id*='cookie']",
  38. "[class*='popup']", "[id*='popup']",
  39. "[class*='modal']", "[id*='modal']",
  40. "[style*='display:none']", "[style*='display: none']", "[hidden]"
  41. ]
  42. end
  43. # Selectors for main content area (in priority order)
  44. #
  45. # @return [Array<String>] CSS selectors for main content
  46. def main_content_selectors
  47. [
  48. "main", "article", "[role='main']",
  49. ".content", "#content", ".main-content",
  50. "#root", "#app", "#__next",
  51. "[class*='container']", "[class*='content']",
  52. "body"
  53. ]
  54. end
  55. # Elements to preserve even if they match removal patterns
  56. #
  57. # @return [Array<String>] CSS selectors to preserve
  58. def elements_to_preserve
  59. []
  60. end
  61. private
  62. def remove_unwanted_elements(doc)
  63. elements_to_remove.each do |selector|
  64. doc.css(selector).each do |el|
  65. # Don't remove if it matches a preservation selector
  66. next if elements_to_preserve.any? { |p| el.matches?(p) rescue false }
  67. el.remove
  68. end
  69. end
  70. doc.xpath("//comment()").remove
  71. end
  72. def extract_main_content(doc)
  73. main_content_selectors.each do |selector|
  74. node = doc.css(selector).first
  75. next unless node
  76. next unless node.text.strip.length >= MIN_CONTENT_LENGTH
  77. return node
  78. end
  79. # Fallback to largest div
  80. body = doc.css("body").first || doc
  81. find_largest_content_div(body) || body
  82. end
  83. def find_largest_content_div(parent)
  84. return nil unless parent
  85. divs = parent.css("div")
  86. return nil if divs.empty?
  87. divs.max_by { |div| div.text.strip.length }
  88. end
  89. def normalize_whitespace(text)
  90. text = text.gsub(/[ \t]+/, " ")
  91. text = text.gsub(/\n{3,}/, "\n\n")
  92. text = text.split("\n").map(&:strip).join("\n")
  93. text.strip
  94. end
  95. def truncate_to_token_limit(text, max_tokens)
  96. max_chars = max_tokens * CHARS_PER_TOKEN
  97. return text if text.length <= max_chars
  98. truncated = text[0...max_chars]
  99. truncated = truncated.sub(/\.[^.]*$/, ".")
  100. truncated
  101. end
  102. end
  103. end
  104. end

app/services/scraping/html_cleaners/cleaner_factory.rb

0.0% lines covered

100.0% branches covered

23 relevant lines. 0 lines covered and 23 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module HtmlCleaners
  4. # Factory for creating board-specific HTML cleaners
  5. #
  6. # Returns specialized cleaners for known job boards, or a generic cleaner otherwise.
  7. class CleanerFactory
  8. CLEANER_MAP = {
  9. ashbyhq: AshbyCleaner,
  10. ashby: AshbyCleaner
  11. # Add more as needed:
  12. # greenhouse: GreenhouseCleaner,
  13. # lever: LeverCleaner,
  14. # workable: WorkableCleaner,
  15. }.freeze
  16. class << self
  17. # Returns appropriate cleaner for the given board type
  18. #
  19. # @param board_type [Symbol, String, nil] The job board type
  20. # @return [BaseCleaner] A cleaner instance
  21. def cleaner_for(board_type)
  22. return BaseCleaner.new if board_type.blank?
  23. key = board_type.to_s.downcase.to_sym
  24. cleaner_class = CLEANER_MAP[key] || BaseCleaner
  25. cleaner_class.new
  26. end
  27. # Returns a cleaner based on URL detection
  28. #
  29. # @param url [String] The job listing URL
  30. # @return [BaseCleaner] A cleaner instance
  31. def cleaner_for_url(url)
  32. return BaseCleaner.new if url.blank?
  33. detector = Scraping::JobBoardDetectorService.new(url)
  34. cleaner_for(detector.detect)
  35. end
  36. end
  37. end
  38. end
  39. end

app/services/scraping/html_fetcher_service.rb

0.0% lines covered

100.0% branches covered

123 relevant lines. 0 lines covered and 123 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Service for idempotent HTML fetching with caching
  4. #
  5. # Fetches HTML content from URLs and caches it in ScrapedJobListingData
  6. # to avoid repeated network requests. Respects validity periods and
  7. # enables retries without re-fetching.
  8. #
  9. # @example
  10. # fetcher = Scraping::HtmlFetcherService.new(job_listing, scraping_attempt)
  11. # result = fetcher.call
  12. # if result[:success]
  13. # html_content = result[:html_content]
  14. # cached_data = result[:cached_data]
  15. # end
  16. class HtmlFetcherService < ApplicationService
  17. include Concerns::Loggable
  18. attr_reader :job_listing, :scraping_attempt, :url
  19. # Initialize the HTML fetcher
  20. #
  21. # @param [JobListing] job_listing The job listing
  22. # @param [ScrapingAttempt, nil] scraping_attempt Optional scraping attempt
  23. def initialize(job_listing, scraping_attempt: nil)
  24. @job_listing = job_listing
  25. @scraping_attempt = scraping_attempt
  26. @url = job_listing.url
  27. end
  28. # Fetches HTML content (from cache or network)
  29. #
  30. # @return [Hash] Result hash with success status, html_content, and cached_data
  31. def call
  32. return error_result("URL is required") if @url.blank?
  33. log_event("html_fetch_started")
  34. # Check for valid cached HTML first
  35. cached_data = find_valid_cache
  36. if cached_data
  37. log_event("html_fetch_succeeded", {
  38. from_cache: true,
  39. valid_until: cached_data.valid_until.iso8601
  40. })
  41. return success_result(
  42. html_content: cached_data.html_content,
  43. cleaned_html: cached_data.cleaned_html,
  44. cached_data: cached_data,
  45. from_cache: true
  46. )
  47. end
  48. # No valid cache, fetch from network
  49. fetch_from_network
  50. end
  51. private
  52. # Finds valid cached HTML for the URL
  53. #
  54. # @return [ScrapedJobListingData, nil] Cached data or nil
  55. def find_valid_cache
  56. ScrapedJobListingData.find_valid_for_url(@url, job_listing: @job_listing)
  57. end
  58. # Fetches HTML from network and caches it
  59. #
  60. # @return [Hash] Result hash
  61. def fetch_from_network
  62. start_time = Time.current
  63. response = HTTParty.get(
  64. @url,
  65. headers: {
  66. "User-Agent" => "GleaniaBot/1.0 (+https://gleania.com/bot)",
  67. "Accept" => "text/html",
  68. "Accept-Language" => "en-US,en;q=0.9"
  69. },
  70. timeout: 30,
  71. open_timeout: 10,
  72. follow_redirects: true,
  73. max_redirects: 3
  74. )
  75. duration = Time.current - start_time
  76. if response.success?
  77. # Save to cache
  78. cached_data = save_to_cache(response, duration)
  79. log_event("html_fetch_succeeded", {
  80. from_cache: false,
  81. http_status: response.code,
  82. duration_seconds: duration
  83. })
  84. success_result(
  85. html_content: response.body,
  86. cleaned_html: cached_data.cleaned_html,
  87. cached_data: cached_data,
  88. from_cache: false,
  89. http_status: response.code
  90. )
  91. else
  92. log_event("html_fetch_failed", {
  93. error: "HTTP #{response.code}: Failed to fetch HTML",
  94. http_status: response.code
  95. })
  96. error_result("HTTP #{response.code}: Failed to fetch HTML", http_status: response.code)
  97. end
  98. rescue Timeout::Error => e
  99. log_error("HTML fetch timeout", e)
  100. error_result("Request timeout: #{e.message}")
  101. rescue => e
  102. log_error("HTML fetch failed", e)
  103. notify_error(
  104. e,
  105. context: "html_fetch",
  106. severity: "error",
  107. url: @url,
  108. job_listing_id: @job_listing.id
  109. )
  110. error_result("Failed to fetch HTML: #{e.message}")
  111. end
  112. # Saves HTML content to cache
  113. #
  114. # @param [HTTParty::Response] response The HTTP response
  115. # @param [Float] duration Fetch duration in seconds
  116. # @return [ScrapedJobListingData] The cached data
  117. def save_to_cache(response, duration)
  118. metadata = {
  119. fetched_at: Time.current.iso8601,
  120. fetched_via: "http",
  121. fetch_mode: "static",
  122. rendered: false,
  123. duration_seconds: duration,
  124. content_length: response.body.length,
  125. headers: response.headers.to_h.slice("content-type", "content-encoding", "last-modified")
  126. }
  127. # Use board-specific cleaner if available, otherwise fall back to generic
  128. cleaner = Scraping::HtmlCleaners::CleanerFactory.cleaner_for_url(@url)
  129. cleaned_html = cleaner.clean(response.body)
  130. ScrapedJobListingData.create_with_html(
  131. url: @url,
  132. html_content: response.body,
  133. job_listing: @job_listing,
  134. scraping_attempt: @scraping_attempt,
  135. http_status: response.code,
  136. metadata: metadata
  137. )
  138. end
  139. # Returns a success result hash
  140. #
  141. # @param [Hash] data Additional data
  142. # @return [Hash] Success result
  143. def success_result(data = {})
  144. {
  145. success: true,
  146. html_content: data[:html_content],
  147. cleaned_html: data[:cleaned_html],
  148. cached_data: data[:cached_data],
  149. from_cache: data[:from_cache] || false,
  150. http_status: data[:http_status]
  151. }
  152. end
  153. # Returns an error result hash
  154. #
  155. # @param [String] error_message The error message
  156. # @param [Hash] additional_data Additional error data
  157. # @return [Hash] Error result
  158. def error_result(error_message, additional_data = {})
  159. {
  160. success: false,
  161. error: error_message,
  162. html_content: nil,
  163. cached_data: nil,
  164. from_cache: false
  165. }.merge(additional_data)
  166. end
  167. end
  168. end

app/services/scraping/html_scraping_service.rb

0.0% lines covered

100.0% branches covered

392 relevant lines. 0 lines covered and 392 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "nokogiri"
  3. module Scraping
  4. # Service for extracting structured job listing data from HTML using Nokogiri
  5. #
  6. # Extracts basic fields from HTML using CSS selectors and text analysis
  7. # to find common job board patterns. This is a fast, cheap extraction method
  8. # that runs before expensive API/AI extraction.
  9. #
  10. # @example
  11. # extractor = Scraping::HtmlScrapingService.new
  12. # data = extractor.extract(html_content, url)
  13. # job_listing.update(data)
  14. class HtmlScrapingService
  15. include Concerns::Loggable
  16. # Field extraction configurations with selectors in priority order
  17. FIELD_SELECTORS = {
  18. title: [
  19. "h1.job-title",
  20. "[data-job-title]",
  21. "[class*='job-title']",
  22. "[id*='job-title']",
  23. "h1",
  24. ".title",
  25. "[class*='title']"
  26. ],
  27. location: [
  28. "[data-location]",
  29. "[class*='location']",
  30. "[id*='location']",
  31. "address",
  32. ".location",
  33. "[class*='address']"
  34. ],
  35. company_name: [
  36. "[data-company]",
  37. "[class*='company']",
  38. "[id*='company']",
  39. ".company",
  40. ".company-name",
  41. "[itemprop='name']"
  42. ],
  43. description: [
  44. "[data-description]",
  45. "[class*='description']",
  46. "[id*='description']",
  47. ".description",
  48. ".job-description",
  49. "main p",
  50. "article p",
  51. "[role='main'] p"
  52. ],
  53. about_company: [
  54. "[data-about]",
  55. "[id*='about']",
  56. "[class*='about']",
  57. ".about",
  58. ".about-us",
  59. ".company-about"
  60. ],
  61. company_culture: [
  62. "[data-culture]",
  63. "[id*='culture']",
  64. "[class*='culture']",
  65. "[id*='values']",
  66. "[class*='values']",
  67. "[id*='mission']",
  68. "[class*='mission']"
  69. ],
  70. salary: [
  71. "[data-salary]",
  72. "[class*='salary']",
  73. "[id*='salary']",
  74. ".salary",
  75. "[class*='compensation']"
  76. ]
  77. }.freeze
  78. # Initialize the HTML scraper
  79. #
  80. # @param [JobListing, nil] job_listing Optional job listing for logging context
  81. # @param [ScrapingAttempt, nil] scraping_attempt Optional scraping attempt for logging context
  82. def initialize(job_listing: nil, scraping_attempt: nil, fetch_mode: nil, board_type: nil, extractor_kind: "generic_html_scraping", run_context: "orchestrator")
  83. @job_listing = job_listing
  84. @scraping_attempt = scraping_attempt
  85. @url = job_listing&.url
  86. @field_results = {}
  87. @selectors_tried = {}
  88. @fetch_mode = fetch_mode
  89. @board_type = board_type
  90. @extractor_kind = extractor_kind
  91. @run_context = run_context
  92. end
  93. # Extracts structured data from HTML
  94. #
  95. # @param [String] html_content The HTML content
  96. # @param [String] url The job listing URL (for logging context)
  97. # @return [Hash] Extracted data hash
  98. def extract(html_content, url = nil)
  99. return {} if html_content.blank?
  100. @url ||= url
  101. @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  102. @html_size = html_content.bytesize
  103. @cleaned_html_size = Scraping::NokogiriHtmlCleanerService.new.clean(html_content).bytesize
  104. log_event("html_scraping_started")
  105. doc = Nokogiri::HTML(html_content)
  106. # Clean up cookie banners first
  107. remove_cookie_banners(doc)
  108. result = {
  109. title: extract_with_tracking(:title, doc) { extract_title(doc) },
  110. location: extract_with_tracking(:location, doc) { extract_location(doc) },
  111. remote_type: extract_with_tracking(:remote_type, doc) { extract_remote_type(doc) },
  112. salary_min: nil,
  113. salary_max: nil,
  114. salary_currency: nil,
  115. description: extract_with_tracking(:description, doc) { extract_description(doc) },
  116. company_name: extract_with_tracking(:company_name, doc) { extract_company_name(doc) },
  117. about_company: extract_with_tracking(:about_company, doc) { extract_about_company(doc) },
  118. company_culture: extract_with_tracking(:company_culture, doc) { extract_company_culture(doc) },
  119. job_role_title: nil
  120. }
  121. # Handle salary separately (multiple fields from one extraction)
  122. salary_data = extract_with_tracking(:salary, doc) { extract_salary_data(doc) }
  123. if salary_data.is_a?(Hash)
  124. result[:salary_min] = salary_data[:min]
  125. result[:salary_max] = salary_data[:max]
  126. result[:salary_currency] = salary_data[:currency]
  127. end
  128. # Job role title comes from title
  129. result[:job_role_title] = result[:title]
  130. # Remove nil values
  131. result.compact!
  132. # Calculate duration
  133. end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  134. @duration_ms = ((end_time - @start_time) * 1000).round
  135. # Create the log record
  136. create_scraping_log(result)
  137. if result.any?
  138. log_event("html_scraping_succeeded", {
  139. fields_extracted: result.keys,
  140. extraction_rate: calculate_extraction_rate,
  141. duration_ms: @duration_ms,
  142. title: result[:title],
  143. location: result[:location]
  144. })
  145. else
  146. log_event("html_scraping_no_data_extracted", {
  147. duration_ms: @duration_ms,
  148. selectors_tried: @selectors_tried.keys
  149. })
  150. end
  151. result
  152. rescue => e
  153. log_error("HTML scraping failed", e)
  154. create_scraping_log({}, error: e)
  155. {}
  156. end
  157. private
  158. # Removes cookie banners and consent dialogs from the document
  159. #
  160. # @param [Nokogiri::HTML::Document] doc The parsed HTML
  161. def remove_cookie_banners(doc)
  162. doc.css("div[id*='cookie'], div[class*='cookie'], div[id*='consent'], div[class*='consent'], div[id*='gdpr'], div[class*='gdpr']").remove
  163. end
  164. # Wraps field extraction with tracking
  165. #
  166. # @param [Symbol] field_name The field being extracted
  167. # @param [Nokogiri::HTML::Document] doc The parsed HTML
  168. # @yield The extraction block
  169. # @return [Object] The extracted value
  170. def extract_with_tracking(field_name, doc)
  171. @selectors_tried[field_name] = []
  172. value = yield
  173. @field_results[field_name] = {
  174. "success" => value.present?,
  175. "value" => truncate_value(value),
  176. "selectors_tried" => @selectors_tried[field_name]
  177. }
  178. if value.present? && @selectors_tried[field_name].any?
  179. @field_results[field_name]["selector"] = @selectors_tried[field_name].last
  180. end
  181. value
  182. end
  183. # Truncates a value for storage
  184. #
  185. # @param [Object] value The value to truncate
  186. # @return [Object] Truncated value
  187. def truncate_value(value)
  188. case value
  189. when String
  190. value.length > 500 ? value[0...500] + "..." : value
  191. when Hash
  192. value.transform_values { |v| truncate_value(v) }
  193. else
  194. value
  195. end
  196. end
  197. # Calculates extraction rate
  198. #
  199. # @return [Float] Extraction rate between 0 and 1
  200. def calculate_extraction_rate
  201. return 0.0 if @field_results.empty?
  202. successful = @field_results.count { |_, v| v["success"] }
  203. successful.to_f / @field_results.count
  204. end
  205. # Creates the HtmlScrapingLog record
  206. #
  207. # @param [Hash] result The extraction result
  208. # @param [Exception, nil] error Optional error
  209. def create_scraping_log(result, error: nil)
  210. return unless @scraping_attempt
  211. domain = begin
  212. URI.parse(@url).host
  213. rescue
  214. "unknown"
  215. end
  216. HtmlScrapingLog.create!(
  217. scraping_attempt: @scraping_attempt,
  218. job_listing: @job_listing,
  219. url: @url,
  220. domain: domain,
  221. html_size: @html_size,
  222. cleaned_html_size: @cleaned_html_size,
  223. duration_ms: @duration_ms,
  224. field_results: @field_results,
  225. selectors_tried: @selectors_tried,
  226. fetch_mode: @fetch_mode,
  227. board_type: @board_type,
  228. extractor_kind: @extractor_kind,
  229. run_context: @run_context,
  230. error_type: error&.class&.name,
  231. error_message: error&.message
  232. )
  233. rescue => e
  234. Rails.logger.error("Failed to create HtmlScrapingLog: #{e.message}")
  235. end
  236. # Extracts job title
  237. #
  238. # @param [Nokogiri::HTML::Document] doc The parsed HTML
  239. # @return [String, nil] Job title
  240. def extract_title(doc)
  241. # Common cookie banner text patterns to skip
  242. cookie_patterns = [
  243. /select which cookies/i,
  244. /accept.*cookies/i,
  245. /cookie.*preferences/i,
  246. /manage.*cookies/i,
  247. /cookie.*consent/i
  248. ]
  249. FIELD_SELECTORS[:title].each do |selector|
  250. @selectors_tried[:title] << selector
  251. element = doc.css(selector).first
  252. if element
  253. title = element.text.strip
  254. # Skip if it looks like cookie banner text
  255. next if cookie_patterns.any? { |pattern| title.match?(pattern) }
  256. return title if title.present? && title.length < 200 && title.length > 3
  257. end
  258. end
  259. nil
  260. end
  261. # Extracts location
  262. #
  263. # @param [Nokogiri::HTML::Document] doc The parsed HTML
  264. # @return [String, nil] Location
  265. def extract_location(doc)
  266. FIELD_SELECTORS[:location].each do |selector|
  267. @selectors_tried[:location] << selector
  268. element = doc.css(selector).first
  269. if element
  270. location = element.text.strip
  271. return location if location.present? && location.length < 200
  272. end
  273. end
  274. # Try to find location in text content
  275. @selectors_tried[:location] << "text_pattern_search"
  276. text = doc.text
  277. location_patterns = [
  278. /(?:Location|Location:)\s*([A-Z][^,\n]{2,50}(?:,\s*[A-Z]{2})?)/i,
  279. /([A-Z][^,\n]{2,50},\s*[A-Z]{2})/,
  280. /(Remote|Hybrid|On-site|Onsite)/i
  281. ]
  282. location_patterns.each do |pattern|
  283. match = text.match(pattern)
  284. return match[1].strip if match && match[1]
  285. end
  286. nil
  287. end
  288. # Infers remote type from content
  289. #
  290. # @param [Nokogiri::HTML::Document] doc The parsed HTML
  291. # @return [Symbol, nil] :remote, :hybrid, or :on_site
  292. def extract_remote_type(doc)
  293. @selectors_tried[:remote_type] ||= []
  294. @selectors_tried[:remote_type] << "text_pattern_search"
  295. text = doc.text.downcase
  296. # Check for explicit remote indicators
  297. if text.match?(/\b(remote|work from home|wfh|distributed|anywhere)\b/i)
  298. return :remote
  299. end
  300. # Check for hybrid indicators
  301. if text.match?(/\b(hybrid|flexible|partially remote)\b/i)
  302. return :hybrid
  303. end
  304. # Check for on-site indicators
  305. if text.match?(/\b(on.?site|on.?premise|in.?office|in.?person)\b/i)
  306. return :on_site
  307. end
  308. nil
  309. end
  310. # Extracts salary information
  311. #
  312. # @param [Nokogiri::HTML::Document] doc The parsed HTML
  313. # @return [Hash] Salary data with :min, :max, :currency
  314. def extract_salary_data(doc)
  315. # First try salary-specific selectors (best signal).
  316. salary_text = nil
  317. salary_context = nil
  318. FIELD_SELECTORS[:salary].each do |selector|
  319. @selectors_tried[:salary] ||= []
  320. @selectors_tried[:salary] << selector
  321. element = doc.css(selector).first
  322. next unless element
  323. salary_text = element.text.to_s
  324. salary_context = salary_text
  325. break
  326. end
  327. # Conservative fallback: only scan lines that likely relate to compensation.
  328. if salary_text.nil?
  329. @selectors_tried[:salary] << "text_pattern_search"
  330. salary_text = compensation_candidate_text(doc.text.to_s)
  331. salary_context = salary_text
  332. end
  333. return {} if salary_text.blank?
  334. parsed = parse_salary_from_text(salary_text)
  335. return {} unless parsed
  336. normalized = Scraping::SalaryRangeValidator.normalize(
  337. min: parsed[:min],
  338. max: parsed[:max],
  339. currency: parsed[:currency],
  340. context_text: salary_context
  341. )
  342. return {} unless normalized[:valid]
  343. { min: normalized[:min], max: normalized[:max], currency: normalized[:currency] }
  344. end
  345. # Extracts only the lines most likely to contain compensation.
  346. #
  347. # This prevents false positives like "89 - 7" pulled from unrelated prose.
  348. #
  349. # @param text [String]
  350. # @return [String]
  351. def compensation_candidate_text(text)
  352. return "" if text.blank?
  353. lines = text.to_s.split(/\r?\n/).map(&:strip).reject(&:blank?)
  354. return "" if lines.empty?
  355. needle = /\b(salary|compensation|pay|remuneration|total\s+comp|ote|base)\b|[$€£]|\b(usd|eur|gbp|pln|chf|cad|aud)\b/i
  356. picked = lines.select { |l| l.match?(needle) }
  357. picked.first(15).join("\n")
  358. end
  359. def parse_salary_from_text(text)
  360. return nil if text.blank?
  361. # Require some "money signal" in the text to avoid matching arbitrary numbers.
  362. money_signal = /\b(salary|compensation|pay|remuneration|total\s+comp|ote|base)\b|[$€£]|\b(usd|eur|gbp|pln|chf|cad|aud)\b/i
  363. return nil unless text.match?(money_signal)
  364. # Range patterns (support k and decimals).
  365. range_patterns = [
  366. /(?<cur>[$€£])?\s*(?<min>\d[\d\s,\.]*\d\s*[kK]?)\s*(?:-|–|—|\bto\b)\s*(?<cur2>[$€£])?\s*(?<max>\d[\d\s,\.]*\d\s*[kK]?)\s*(?<code>[A-Z]{3})?/i,
  367. /(?<min>\d[\d\s,\.]*\d\s*[kK]?)\s*(?<code>[A-Z]{3})\s*(?:-|–|—|\bto\b)\s*(?<max>\d[\d\s,\.]*\d\s*[kK]?)/i
  368. ]
  369. range_patterns.each do |re|
  370. m = text.match(re)
  371. next unless m
  372. currency = currency_from_match(m)
  373. return nil if currency.blank?
  374. return {
  375. min: m[:min],
  376. max: m[:max],
  377. currency: currency
  378. }
  379. end
  380. # Single "min+" pattern. We still require a currency signal.
  381. single_re = /(?<cur>[$€£])?\s*(?<min>\d[\d\s,\.]*\d\s*[kK]?)\s*\+\s*(?<code>[A-Z]{3})?/i
  382. m = text.match(single_re)
  383. return nil unless m
  384. currency = currency_from_match(m)
  385. return nil if currency.blank?
  386. {
  387. min: m[:min],
  388. max: nil,
  389. currency: currency
  390. }
  391. end
  392. def currency_from_match(match)
  393. code = match[:code].to_s.strip.upcase.presence
  394. return code if code.present?
  395. symbol = (match[:cur] || match[:cur2]).to_s.strip
  396. case symbol
  397. when "$" then "USD"
  398. when "€" then "EUR"
  399. when "£" then "GBP"
  400. else
  401. nil
  402. end
  403. end
  404. # Extracts job description summary
  405. #
  406. # @param [Nokogiri::HTML::Document] doc The parsed HTML
  407. # @return [String, nil] Description summary
  408. def extract_description(doc)
  409. FIELD_SELECTORS[:description].each do |selector|
  410. @selectors_tried[:description] << selector
  411. elements = doc.css(selector)
  412. next if elements.empty?
  413. # Get first paragraph or concatenate first few
  414. text = elements.first(3).map(&:text).join("\n\n").strip
  415. return text[0...2000] if text.present? # Limit to 2000 chars
  416. end
  417. nil
  418. end
  419. # Extracts company name
  420. #
  421. # @param [Nokogiri::HTML::Document] doc The parsed HTML
  422. # @return [String, nil] Company name
  423. def extract_company_name(doc)
  424. FIELD_SELECTORS[:company_name].each do |selector|
  425. @selectors_tried[:company_name] << selector
  426. element = doc.css(selector).first
  427. if element
  428. name = element["content"] || element["alt"] || element.text
  429. name = name.to_s.strip
  430. return name if name.present? && name.length < 100
  431. end
  432. end
  433. # Try meta tags
  434. @selectors_tried[:company_name] << "meta_tags"
  435. meta_company = doc.css("meta[property='og:site_name'], meta[name='company']").first
  436. if meta_company
  437. name = meta_company["content"] || meta_company["value"]
  438. return name.strip if name.present?
  439. end
  440. nil
  441. end
  442. def extract_about_company(doc)
  443. text = extract_block_from_selectors(doc, :about_company, max_chars: 2000)
  444. return text if text.present?
  445. extract_section_by_heading(doc, /about\s+(the\s+)?company|about\s+us|who\s+we\s+are/i, max_chars: 2000)
  446. end
  447. def extract_company_culture(doc)
  448. text = extract_block_from_selectors(doc, :company_culture, max_chars: 2000)
  449. return text if text.present?
  450. extract_section_by_heading(doc, /culture|values|mission|principles|how\s+we\s+work/i, max_chars: 2000)
  451. end
  452. def extract_block_from_selectors(doc, field, max_chars:)
  453. FIELD_SELECTORS[field].each do |selector|
  454. @selectors_tried[field] << selector
  455. element = doc.css(selector).first
  456. next unless element
  457. text = element.text.to_s.squish
  458. return text[0...max_chars] if text.present?
  459. end
  460. nil
  461. end
  462. def extract_section_by_heading(doc, heading_regex, max_chars:)
  463. headings = doc.css("h1, h2, h3, h4, strong, b")
  464. headings.each do |heading|
  465. next unless heading.text.to_s.squish.match?(heading_regex)
  466. # Collect siblings until next heading-like element
  467. chunks = []
  468. node = heading
  469. while (node = node.next_sibling)
  470. break if node.element? && node.name.to_s.match?(/\Ah[1-6]\z/i)
  471. next if node.text?
  472. text = node.text.to_s.squish
  473. next if text.blank?
  474. chunks << text
  475. break if chunks.join("\n\n").length >= max_chars
  476. end
  477. combined = chunks.join("\n\n").strip
  478. return combined[0...max_chars] if combined.present?
  479. end
  480. nil
  481. end
  482. end
  483. end

app/services/scraping/job_board_detector_service.rb

0.0% lines covered

100.0% branches covered

132 relevant lines. 0 lines covered and 132 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Service for detecting job board type from URL
  4. #
  5. # Analyzes URLs to determine which job board or ATS platform is being used,
  6. # enabling platform-specific extraction strategies.
  7. #
  8. # @example
  9. # detector = Scraping::JobBoardDetectorService.new("https://boards.greenhouse.io/company/jobs/123")
  10. # detector.detect # => :greenhouse
  11. # detector.company_slug # => "company"
  12. # detector.job_id # => "123"
  13. class JobBoardDetectorService
  14. # Board types that have API integrations
  15. API_SUPPORTED_BOARDS = [ :greenhouse, :lever ].freeze
  16. # Board types with limited extraction capability (require auth, heavy JS, etc.)
  17. LIMITED_EXTRACTION_BOARDS = [ :linkedin, :indeed, :glassdoor ].freeze
  18. # Initialize detector with a URL
  19. #
  20. # @param [String] url The job listing URL to analyze
  21. def initialize(url)
  22. @url = url
  23. @uri = URI.parse(url)
  24. end
  25. # Detects the job board type from the URL
  26. #
  27. # @return [Symbol] Job board type (:linkedin, :greenhouse, :lever, etc.)
  28. def detect
  29. return :greenhouse if greenhouse?
  30. return :lever if lever?
  31. return :linkedin if linkedin?
  32. return :indeed if indeed?
  33. return :glassdoor if glassdoor?
  34. return :workable if workable?
  35. return :jobvite if jobvite?
  36. return :icims if icims?
  37. return :smartrecruiters if smartrecruiters?
  38. return :bamboohr if bamboohr?
  39. return :ashbyhq if ashbyhq?
  40. :unknown
  41. end
  42. # Checks if this board type has API support
  43. #
  44. # @return [Boolean] True if API integration is available
  45. def api_supported?
  46. API_SUPPORTED_BOARDS.include?(detect)
  47. end
  48. # Checks if this board type has limited extraction capability
  49. # (requires authentication, heavy JS rendering, or blocks scraping)
  50. #
  51. # @return [Boolean] True if extraction is limited
  52. def limited_extraction?
  53. LIMITED_EXTRACTION_BOARDS.include?(detect)
  54. end
  55. # Extracts company slug/identifier from URL
  56. #
  57. # @return [String, nil] Company identifier or nil
  58. def company_slug
  59. case detect
  60. when :greenhouse
  61. extract_greenhouse_company
  62. when :lever
  63. extract_lever_company
  64. when :workable
  65. extract_workable_company
  66. else
  67. nil
  68. end
  69. end
  70. # Extracts job ID from URL
  71. #
  72. # @return [String, nil] Job ID or nil
  73. def job_id
  74. # Lever URLs are typically /<company>/<job-id>
  75. if detect == :lever
  76. segments = @uri.path.to_s.split("/").reject(&:blank?)
  77. return segments[1] if segments.length >= 2
  78. end
  79. # LinkedIn has specific patterns
  80. if detect == :linkedin
  81. return extract_linkedin_job_id
  82. end
  83. # Try to find job ID in common patterns
  84. patterns = [
  85. %r{/jobs?/(\d+)}, # /jobs/123
  86. %r{/positions?/(\d+)}, # /positions/123
  87. %r{/careers?/(\d+)}, # /careers/123
  88. %r{/job/([^/\?]+)}, # /job/some-id
  89. %r{/position/([^/\?]+)}, # /position/some-id
  90. %r{job_id=([^&]+)}, # ?job_id=123
  91. %r{gh_jid=([^&]+)} # Greenhouse job ID param
  92. ]
  93. patterns.each do |pattern|
  94. match = @url.match(pattern)
  95. return match[1] if match
  96. end
  97. nil
  98. end
  99. # Returns the canonical URL for this job listing
  100. # Useful for normalizing different URL formats that point to the same job
  101. #
  102. # @return [String] Canonical URL
  103. def canonical_url
  104. case detect
  105. when :linkedin
  106. job = extract_linkedin_job_id
  107. job ? "https://www.linkedin.com/jobs/view/#{job}" : @url
  108. else
  109. @url
  110. end
  111. end
  112. private
  113. # Checks if URL is from Greenhouse
  114. def greenhouse?
  115. @uri.host&.include?("greenhouse.io") ||
  116. @url.include?("boards.greenhouse.io") ||
  117. @url.include?("gh_jid=")
  118. end
  119. # Checks if URL is from Lever
  120. def lever?
  121. @uri.host&.include?("lever.co") ||
  122. @url.include?("jobs.lever.co")
  123. end
  124. # Checks if URL is from LinkedIn
  125. def linkedin?
  126. @uri.host&.include?("linkedin.com")
  127. end
  128. # Extracts job ID from LinkedIn URL
  129. # Handles multiple formats:
  130. # - /jobs/view/123456789
  131. # - /jobs/collections/recommended/?currentJobId=123456789
  132. # - /jobs/search/?currentJobId=123456789
  133. #
  134. # @return [String, nil] Job ID or nil
  135. def extract_linkedin_job_id
  136. # Direct job view: /jobs/view/123456789
  137. view_match = @url.match(%r{/jobs/view/(\d+)})
  138. return view_match[1] if view_match
  139. # Collection/search with currentJobId param
  140. param_match = @url.match(/currentJobId=(\d+)/)
  141. return param_match[1] if param_match
  142. nil
  143. end
  144. # Checks if URL is from Indeed
  145. def indeed?
  146. @uri.host&.include?("indeed.com")
  147. end
  148. # Checks if URL is from Glassdoor
  149. def glassdoor?
  150. @uri.host&.include?("glassdoor.com")
  151. end
  152. # Checks if URL is from Workable
  153. def workable?
  154. @uri.host&.include?("workable.com") ||
  155. @url.include?("apply.workable.com")
  156. end
  157. # Checks if URL is from Jobvite
  158. def jobvite?
  159. @uri.host&.include?("jobvite.com")
  160. end
  161. # Checks if URL is from iCIMS
  162. def icims?
  163. @uri.host&.include?("icims.com")
  164. end
  165. # Checks if URL is from SmartRecruiters
  166. def smartrecruiters?
  167. @uri.host&.include?("smartrecruiters.com")
  168. end
  169. # Checks if URL is from BambooHR
  170. def bamboohr?
  171. @uri.host&.include?("bamboohr.com")
  172. end
  173. # Checks if URL is from Ashby
  174. def ashbyhq?
  175. @uri.host&.include?("ashbyhq.com") ||
  176. @uri.host&.include?("jobs.ashbyhq.com")
  177. end
  178. # Extracts company slug from Greenhouse URL
  179. # Example: https://boards.greenhouse.io/company-name/jobs/123
  180. def extract_greenhouse_company
  181. match = @url.match(%r{boards\.greenhouse\.io/([^/]+)})
  182. match ? match[1] : nil
  183. end
  184. # Extracts company slug from Lever URL
  185. # Example: https://jobs.lever.co/company-name/job-id
  186. def extract_lever_company
  187. match = @url.match(%r{jobs\.lever\.co/([^/]+)})
  188. match ? match[1] : nil
  189. end
  190. # Extracts company slug from Workable URL
  191. # Example: https://apply.workable.com/company-name/
  192. def extract_workable_company
  193. match = @url.match(%r{apply\.workable\.com/([^/]+)})
  194. match ? match[1] : nil
  195. end
  196. end
  197. end

app/services/scraping/job_boards/ashby_extractor.rb

0.0% lines covered

100.0% branches covered

85 relevant lines. 0 lines covered and 85 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. # Extractor for Ashby job board pages (jobs.ashbyhq.com)
  5. #
  6. # Ashby uses React with dynamically generated class names like `_title_ud4nd_34`
  7. # but also has stable semantic classes like `ashby-job-posting-heading`.
  8. #
  9. # Page structure:
  10. # - Header: Company logo with alt text, back button
  11. # - Left pane (.ashby-job-posting-left-pane): Location, Employment Type, Location Type, Department
  12. # - Right pane (.ashby-job-posting-right-pane): Full job description with h2 sections
  13. # - Title in h1 with class containing "_title_" or .ashby-job-posting-heading
  14. # - Company name: In title tag after "@" or in logo img[alt]
  15. #
  16. # NOTE: This extractor focuses on metadata extraction (title, company, location).
  17. # The job description contains structured sections (Responsibilities, Requirements)
  18. # that are better parsed by AI extraction. We intentionally return only the raw
  19. # description to let AI handle the structured extraction.
  20. class AshbyExtractor < BaseExtractor
  21. protected
  22. def title_selectors
  23. [
  24. ".ashby-job-posting-heading",
  25. "h1[class*='_title_']",
  26. "h1",
  27. "meta[property='og:title']"
  28. ]
  29. end
  30. def company_selectors
  31. [
  32. # Company name is in the logo alt text
  33. ".ashby-job-posting-header img[alt]",
  34. "[class*='_navLogoWordmarkImage_']",
  35. # Or extract from title tag pattern "Job Title @ Company"
  36. "title"
  37. ]
  38. end
  39. def location_selectors
  40. [
  41. # Left pane sections have Location heading
  42. ".ashby-job-posting-left-pane [class*='_section_']:first-of-type p",
  43. "[class*='_section_'] p",
  44. "meta[property='og:locale']"
  45. ]
  46. end
  47. def description_selectors
  48. [
  49. ".ashby-job-posting-right-pane",
  50. "[class*='_details_']",
  51. "[class*='_content_']",
  52. "meta[name='description']"
  53. ]
  54. end
  55. # Ashby embeds about_company in the description - don't duplicate
  56. # Let AI extraction parse this from the description content
  57. def about_company_selectors
  58. []
  59. end
  60. # Requirements are in h2 sections within the description
  61. # Let AI extraction parse these from the description content
  62. def requirements_selectors
  63. []
  64. end
  65. # Responsibilities are in h2 sections within the description
  66. # Let AI extraction parse these from the description content
  67. def responsibilities_selectors
  68. []
  69. end
  70. # Ashby has company culture info embedded in description
  71. def company_culture_selectors
  72. []
  73. end
  74. # Override confidence calculation for Ashby
  75. #
  76. # Ashby HTML extraction only gives us metadata (title, company, location)
  77. # and raw description. The structured fields (requirements, responsibilities)
  78. # need AI extraction to parse from the description content.
  79. #
  80. # Cap confidence at 0.65 to ensure AI extraction runs for structured parsing.
  81. def confidence_for(data)
  82. base_score = super(data)
  83. # If we only have basic metadata, cap confidence to trigger AI extraction
  84. has_structured_data = data[:requirements].present? || data[:responsibilities].present?
  85. return base_score if has_structured_data
  86. # Cap at 0.65 to ensure AI extraction runs for structured parsing
  87. [ base_score, 0.65 ].min
  88. end
  89. private
  90. # Override pick_text to handle special cases for Ashby
  91. def pick_text(doc, selectors, selectors_tried, field)
  92. selectors_tried[field.to_s] = []
  93. selectors.each do |selector|
  94. selectors_tried[field.to_s] << selector
  95. node = doc.css(selector).first
  96. next unless node
  97. # Handle special extraction for company from title tag
  98. if field == :company_name && selector == "title"
  99. title_text = node.text.to_s
  100. # Extract company from "Job Title @ Company" pattern
  101. if title_text.include?("@")
  102. company = title_text.split("@").last&.strip
  103. return company if company.present?
  104. end
  105. next
  106. end
  107. # Handle img alt attribute for company logo
  108. if node.name == "img" && node["alt"].present?
  109. return node["alt"].strip
  110. end
  111. # Handle meta tags
  112. raw = node["content"] || node["alt"] || node["aria-label"] || node["title"] || node.text
  113. text = raw.to_s.squish
  114. # For short fields (location), accept shorter values
  115. # For long fields (description), require more content
  116. min_length = case field
  117. when :description
  118. 50
  119. else
  120. 1 # Accept any non-empty value for short fields
  121. end
  122. return text if text.present? && text.length >= min_length
  123. end
  124. nil
  125. end
  126. end
  127. end
  128. end

app/services/scraping/job_boards/bamboo_hr_extractor.rb

0.0% lines covered

100.0% branches covered

27 relevant lines. 0 lines covered and 27 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. class BambooHrExtractor < BaseExtractor
  5. protected
  6. def title_selectors
  7. [
  8. "h1",
  9. "[class*='BambooHR'] h1",
  10. "[data-automation-id='jobPostingHeader']"
  11. ]
  12. end
  13. def location_selectors
  14. [
  15. "[class*='location']",
  16. "[data-automation-id='jobPostingLocation']"
  17. ]
  18. end
  19. def description_selectors
  20. [
  21. "[class*='jobDescription']",
  22. "[class*='description']",
  23. "main"
  24. ]
  25. end
  26. end
  27. end
  28. end

app/services/scraping/job_boards/base_extractor.rb

0.0% lines covered

100.0% branches covered

126 relevant lines. 0 lines covered and 126 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "nokogiri"
  3. module Scraping
  4. module JobBoards
  5. # Base extractor for job-board HTML pages using selectors-first strategy.
  6. #
  7. # Subclasses should provide selector lists for key fields.
  8. class BaseExtractor
  9. REQUIRED_FIELDS = %i[title company_name description].freeze
  10. IMPORTANT_FIELDS = %i[location requirements responsibilities benefits].freeze
  11. attr_reader :board_type
  12. def initialize(board_type:)
  13. @board_type = board_type
  14. end
  15. # Extracts structured data from HTML
  16. #
  17. # @param html_content [String]
  18. # @return [Hash] normalized extraction result
  19. def extract(html_content)
  20. return failure("No HTML provided") if html_content.blank?
  21. doc = Nokogiri::HTML(html_content)
  22. selectors_tried = {}
  23. data = {
  24. title: pick_text(doc, title_selectors, selectors_tried, :title),
  25. company_name: pick_text(doc, company_selectors, selectors_tried, :company_name),
  26. location: pick_text(doc, location_selectors, selectors_tried, :location),
  27. description: pick_text(doc, description_selectors, selectors_tried, :description),
  28. about_company: pick_text(doc, about_company_selectors, selectors_tried, :about_company),
  29. company_culture: pick_text(doc, company_culture_selectors, selectors_tried, :company_culture),
  30. requirements: pick_text(doc, requirements_selectors, selectors_tried, :requirements),
  31. responsibilities: pick_text(doc, responsibilities_selectors, selectors_tried, :responsibilities)
  32. }.compact
  33. missing_fields = REQUIRED_FIELDS.reject { |f| data[f].present? }
  34. confidence = confidence_for(data)
  35. {
  36. # success here means "required fields present"; acceptance is decided by orchestrator via confidence threshold.
  37. success: missing_fields.empty?,
  38. extractor_kind: "job_board_selectors",
  39. board_type: board_type.to_s,
  40. extraction_method: "html",
  41. provider: board_type.to_s,
  42. confidence: confidence,
  43. missing_fields: missing_fields.map(&:to_s),
  44. extracted_fields: data.keys.map(&:to_s),
  45. selectors_tried: selectors_tried,
  46. data: data
  47. }
  48. rescue StandardError => e
  49. failure(e.message)
  50. end
  51. protected
  52. def title_selectors
  53. [ "h1" ]
  54. end
  55. def company_selectors
  56. []
  57. end
  58. def location_selectors
  59. [ "[class*='location']", "[data-location]", "address" ]
  60. end
  61. def description_selectors
  62. [ "[class*='description']", "[data-description]", "main", "article" ]
  63. end
  64. def requirements_selectors
  65. []
  66. end
  67. def responsibilities_selectors
  68. []
  69. end
  70. def about_company_selectors
  71. [
  72. "[id*='about']",
  73. "[class*='about']",
  74. ".about",
  75. ".about-us"
  76. ]
  77. end
  78. def company_culture_selectors
  79. [
  80. "[id*='culture']",
  81. "[class*='culture']",
  82. "[id*='values']",
  83. "[class*='values']",
  84. "[id*='mission']",
  85. "[class*='mission']"
  86. ]
  87. end
  88. def confidence_for(data)
  89. # Weighted score emphasizing must-have fields.
  90. weights = {
  91. title: 0.25,
  92. company_name: 0.25,
  93. description: 0.15,
  94. location: 0.05,
  95. requirements: 0.075,
  96. responsibilities: 0.075,
  97. benefits: 0.05,
  98. about_company: 0.05,
  99. company_culture: 0.05
  100. }
  101. score = weights.sum { |field, w| data[field].present? ? w : 0.0 }
  102. # If any required field is missing, cap confidence so we continue to AI.
  103. missing_required = REQUIRED_FIELDS.any? { |f| data[f].blank? }
  104. score = [ score, 0.69 ].min if missing_required
  105. score.clamp(0.0, 1.0)
  106. end
  107. private
  108. def pick_text(doc, selectors, selectors_tried, field)
  109. selectors_tried[field.to_s] = []
  110. selectors.each do |selector|
  111. selectors_tried[field.to_s] << selector
  112. node = doc.css(selector).first
  113. next unless node
  114. raw = node["content"] || node["alt"] || node["aria-label"] || node["title"] || node.text
  115. text = raw.to_s.squish
  116. return text if text.present?
  117. end
  118. nil
  119. end
  120. def failure(message)
  121. {
  122. success: false,
  123. extractor_kind: "job_board_selectors",
  124. board_type: board_type.to_s,
  125. extraction_method: "html",
  126. provider: board_type.to_s,
  127. confidence: 0.0,
  128. error: message,
  129. missing_fields: REQUIRED_FIELDS.map(&:to_s),
  130. extracted_fields: [],
  131. selectors_tried: {},
  132. data: {}
  133. }
  134. end
  135. end
  136. end
  137. end

app/services/scraping/job_boards/extractor_factory.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. # Factory for mapping detected board types to selectors-first extractors.
  5. class ExtractorFactory
  6. def self.build(board_type)
  7. case board_type&.to_sym
  8. when :greenhouse
  9. GreenhouseExtractor.new(board_type: :greenhouse)
  10. when :lever
  11. LeverExtractor.new(board_type: :lever)
  12. when :workable
  13. WorkableExtractor.new(board_type: :workable)
  14. when :ashbyhq
  15. AshbyExtractor.new(board_type: :ashbyhq)
  16. when :smartrecruiters
  17. SmartRecruitersExtractor.new(board_type: :smartrecruiters)
  18. when :bamboohr
  19. BambooHrExtractor.new(board_type: :bamboohr)
  20. when :icims
  21. IcimsExtractor.new(board_type: :icims)
  22. when :jobvite
  23. JobviteExtractor.new(board_type: :jobvite)
  24. else
  25. BaseExtractor.new(board_type: (board_type || :unknown))
  26. end
  27. end
  28. end
  29. end
  30. end

app/services/scraping/job_boards/greenhouse_extractor.rb

0.0% lines covered

100.0% branches covered

56 relevant lines. 0 lines covered and 56 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. class GreenhouseExtractor < BaseExtractor
  5. protected
  6. def title_selectors
  7. [
  8. "h1.app-title",
  9. "h1",
  10. "[data-testid='job-title']"
  11. ]
  12. end
  13. def company_selectors
  14. [
  15. ".company-name",
  16. "[class*='company'] a",
  17. "meta[property='og:site_name']"
  18. ]
  19. end
  20. def location_selectors
  21. [
  22. "#location",
  23. ".location",
  24. "[class*='location']"
  25. ]
  26. end
  27. def description_selectors
  28. [
  29. "#content",
  30. "#job_description",
  31. ".content",
  32. "[id*='description']",
  33. "[class*='description']"
  34. ]
  35. end
  36. def about_company_selectors
  37. [
  38. "#content",
  39. "[id*='about']",
  40. "[class*='about']",
  41. ".about",
  42. ".about-us"
  43. ]
  44. end
  45. def company_culture_selectors
  46. [
  47. "[id*='culture']",
  48. "[class*='culture']",
  49. "[id*='values']",
  50. "[class*='values']",
  51. "[id*='mission']",
  52. "[class*='mission']"
  53. ]
  54. end
  55. end
  56. end
  57. end

app/services/scraping/job_boards/icims_extractor.rb

0.0% lines covered

100.0% branches covered

28 relevant lines. 0 lines covered and 28 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. class IcimsExtractor < BaseExtractor
  5. protected
  6. def title_selectors
  7. [
  8. "h1",
  9. "#header h1",
  10. "[class*='iCIMS'] h1"
  11. ]
  12. end
  13. def location_selectors
  14. [
  15. "[class*='location']",
  16. "[id*='location']"
  17. ]
  18. end
  19. def description_selectors
  20. [
  21. "#job-content",
  22. "[id*='jobDescription']",
  23. "[class*='description']",
  24. "main"
  25. ]
  26. end
  27. end
  28. end
  29. end

app/services/scraping/job_boards/jobvite_extractor.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. class JobviteExtractor < BaseExtractor
  5. protected
  6. def title_selectors
  7. [
  8. "h1",
  9. "[class*='jv-header'] h1",
  10. "[class*='job-title']"
  11. ]
  12. end
  13. def company_selectors
  14. [
  15. "meta[property='og:site_name']",
  16. "[class*='company']"
  17. ]
  18. end
  19. def location_selectors
  20. [
  21. "[class*='jv-job-location']",
  22. "[class*='location']"
  23. ]
  24. end
  25. def description_selectors
  26. [
  27. "[class*='jv-job-detail-description']",
  28. "[class*='description']",
  29. "main"
  30. ]
  31. end
  32. end
  33. end
  34. end

app/services/scraping/job_boards/lever_extractor.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. class LeverExtractor < BaseExtractor
  5. protected
  6. def title_selectors
  7. [
  8. "h2.posting-headline",
  9. "h1",
  10. "[data-qa='posting-name']"
  11. ]
  12. end
  13. def company_selectors
  14. [
  15. ".main-header-logo img[alt]",
  16. "meta[property='og:site_name']"
  17. ]
  18. end
  19. def location_selectors
  20. [
  21. ".posting-categories .location",
  22. "[class*='location']"
  23. ]
  24. end
  25. def description_selectors
  26. [
  27. ".posting-description",
  28. "[class*='posting-description']",
  29. "[class*='description']"
  30. ]
  31. end
  32. end
  33. end
  34. end

app/services/scraping/job_boards/smart_recruiters_extractor.rb

0.0% lines covered

100.0% branches covered

34 relevant lines. 0 lines covered and 34 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. class SmartRecruitersExtractor < BaseExtractor
  5. protected
  6. def title_selectors
  7. [
  8. "h1",
  9. "[data-testid='job-title']",
  10. "[class*='job-title']"
  11. ]
  12. end
  13. def company_selectors
  14. [
  15. "meta[property='og:site_name']",
  16. "[class*='company']"
  17. ]
  18. end
  19. def location_selectors
  20. [
  21. "[data-testid='job-location']",
  22. "[class*='location']"
  23. ]
  24. end
  25. def description_selectors
  26. [
  27. "[data-testid='job-description']",
  28. "[class*='job-description']",
  29. "[class*='description']",
  30. "main"
  31. ]
  32. end
  33. end
  34. end
  35. end

app/services/scraping/job_boards/workable_extractor.rb

0.0% lines covered

100.0% branches covered

34 relevant lines. 0 lines covered and 34 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module JobBoards
  4. class WorkableExtractor < BaseExtractor
  5. protected
  6. def title_selectors
  7. [
  8. "h1[data-ui='job-title']",
  9. "h1",
  10. "[class*='job-title']"
  11. ]
  12. end
  13. def company_selectors
  14. [
  15. "[data-ui='company-name']",
  16. "[class*='company']",
  17. "meta[property='og:site_name']"
  18. ]
  19. end
  20. def location_selectors
  21. [
  22. "[data-ui='job-location']",
  23. "[class*='location']"
  24. ]
  25. end
  26. def description_selectors
  27. [
  28. "[data-ui='job-description']",
  29. "[class*='job-description']",
  30. "[class*='description']"
  31. ]
  32. end
  33. end
  34. end
  35. end

app/services/scraping/nokogiri_html_cleaner_service.rb

0.0% lines covered

100.0% branches covered

69 relevant lines. 0 lines covered and 69 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "nokogiri"
  3. module Scraping
  4. # Service for cleaning HTML content using Nokogiri
  5. #
  6. # Extracts main content, removes unwanted elements, and optimizes
  7. # for LLM token limits. Uses semantic HTML5 elements and common
  8. # job board patterns to find the main content area.
  9. #
  10. # For board-specific cleaning, use HtmlCleaners::CleanerFactory instead.
  11. #
  12. # @example Basic usage
  13. # cleaner = Scraping::NokogiriHtmlCleanerService.new
  14. # cleaned_text = cleaner.clean(html_content)
  15. #
  16. # @example Board-specific cleaning
  17. # cleaner = Scraping::HtmlCleaners::CleanerFactory.cleaner_for(:ashbyhq)
  18. # cleaned_text = cleaner.clean(html_content)
  19. class NokogiriHtmlCleanerService
  20. MAX_TOKENS = 25_000 # Conservative limit to stay under 30k tokens/minute
  21. CHARS_PER_TOKEN = 3 # Conservative estimate: 1 token ≈ 3 chars for HTML
  22. # Cleans HTML content and returns optimized text
  23. #
  24. # @param [String] html_content The raw HTML content
  25. # @return [String] Cleaned text content optimized for LLM
  26. def clean(html_content)
  27. return "" if html_content.blank?
  28. doc = Nokogiri::HTML(html_content)
  29. # Remove unwanted elements first
  30. remove_unwanted_elements(doc)
  31. # Extract main content
  32. main_content = extract_main_content(doc)
  33. # Convert to text and clean up whitespace
  34. text = main_content.text
  35. text = normalize_whitespace(text)
  36. # Truncate to token limit
  37. truncate_to_token_limit(text, MAX_TOKENS)
  38. end
  39. private
  40. # Removes unwanted elements from the document
  41. #
  42. # @param [Nokogiri::HTML::Document] doc The parsed HTML document
  43. def remove_unwanted_elements(doc)
  44. # Remove script and style tags
  45. doc.css("script, style").remove
  46. # Remove navigation elements
  47. doc.css("nav, header, footer").remove
  48. # Remove common ad/tracking containers
  49. doc.css("[class*='ad'], [class*='advertisement'], [id*='ad'], [id*='advertisement']").remove
  50. doc.css("[class*='tracking'], [class*='analytics'], [id*='tracking']").remove
  51. # Remove social media widgets
  52. doc.css("[class*='social'], [class*='share'], [id*='social'], [id*='share']").remove
  53. # Remove comments
  54. doc.xpath("//comment()").remove
  55. # Remove hidden elements
  56. doc.css("[style*='display:none'], [style*='display: none'], [hidden]").remove
  57. end
  58. # Extracts the main content area from the document
  59. #
  60. # @param [Nokogiri::HTML::Document] doc The parsed HTML document
  61. # @return [Nokogiri::XML::Node] The main content node
  62. def extract_main_content(doc)
  63. # Minimum content length threshold - skip elements with too little text
  64. min_content_length = 100
  65. # Try semantic HTML5 elements first
  66. main = doc.css("main, article, [role='main']").first
  67. return main if main && main.text.strip.length >= min_content_length
  68. # Try common content class names
  69. content = doc.css(".content, #content, .main-content, .job-content, .job-description").first
  70. return content if content && content.text.strip.length >= min_content_length
  71. # Try React/SPA root containers (common for Ashby, Greenhouse, Lever, etc.)
  72. # Check these BEFORE job-specific selectors as they usually contain the full content
  73. react_root = doc.css("#root, #app, #__next").first
  74. return react_root if react_root && react_root.text.strip.length >= min_content_length
  75. # Try container patterns with dynamic class names (e.g., _container_xyz, _content_xyz)
  76. container = doc.css("[class*='container'], [class*='content'], [class*='posting']").first
  77. return container if container && container.text.strip.length >= min_content_length
  78. # Try job-specific selectors (be careful - these can match small nav elements)
  79. job_content = doc.css("[class*='job-description'], [class*='job-details'], [class*='job-posting'], [id*='job-description']").first
  80. return job_content if job_content && job_content.text.strip.length >= min_content_length
  81. # Try to find the largest text-containing div
  82. body = doc.css("body").first || doc
  83. largest_div = find_largest_content_div(body)
  84. return largest_div if largest_div && largest_div.text.strip.length >= min_content_length
  85. # Fallback to body
  86. body
  87. end
  88. # Finds the div with the most text content (likely the main content area)
  89. #
  90. # @param [Nokogiri::XML::Node] parent The parent node to search within
  91. # @return [Nokogiri::XML::Node, nil] The largest content div or nil
  92. def find_largest_content_div(parent)
  93. return nil unless parent
  94. divs = parent.css("div")
  95. return nil if divs.empty?
  96. # Find the div with the most text content
  97. divs.max_by { |div| div.text.strip.length }
  98. end
  99. # Normalizes whitespace in text
  100. #
  101. # @param [String] text The text to normalize
  102. # @return [String] Normalized text
  103. def normalize_whitespace(text)
  104. # Replace multiple spaces with single space
  105. text = text.gsub(/[ \t]+/, " ")
  106. # Replace multiple newlines with double newline (paragraph break)
  107. text = text.gsub(/\n{3,}/, "\n\n")
  108. # Remove leading/trailing whitespace from each line
  109. text = text.split("\n").map(&:strip).join("\n")
  110. # Final cleanup
  111. text.strip
  112. end
  113. # Truncates text to stay within token limit
  114. #
  115. # @param [String] text The text to truncate
  116. # @param [Integer] max_tokens Maximum tokens allowed
  117. # @return [String] Truncated text
  118. def truncate_to_token_limit(text, max_tokens)
  119. max_chars = max_tokens * CHARS_PER_TOKEN
  120. return text if text.length <= max_chars
  121. # Truncate to max length
  122. truncated = text[0...max_chars]
  123. # Try to end at a sentence boundary
  124. truncated = truncated.sub(/\.[^.]*$/, ".")
  125. # If still too long, truncate more aggressively
  126. while estimate_tokens(truncated) > max_tokens && truncated.length > 10_000
  127. truncated = truncated[0...(truncated.length * 0.9).to_i]
  128. truncated = truncated.sub(/\.[^.]*$/, ".")
  129. end
  130. truncated
  131. end
  132. # Estimates token count for text
  133. #
  134. # @param [String] text The text to estimate
  135. # @return [Integer] Estimated token count
  136. def estimate_tokens(text)
  137. (text.length.to_f / CHARS_PER_TOKEN).ceil
  138. end
  139. end
  140. end

app/services/scraping/orchestration/context.rb

0.0% lines covered

100.0% branches covered

26 relevant lines. 0 lines covered and 26 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. # Shared state for a scraping orchestration run.
  5. class Context
  6. CONFIDENCE_THRESHOLD = 0.7
  7. attr_reader :job_listing, :attempt, :event_recorder, :started_at
  8. attr_accessor :detector, :board_type, :company_slug, :job_id
  9. attr_accessor :html_content, :cleaned_html, :fetch_mode
  10. def initialize(job_listing:, attempt:, event_recorder:)
  11. @job_listing = job_listing
  12. @attempt = attempt
  13. @event_recorder = event_recorder
  14. @started_at = Time.current
  15. @detector = nil
  16. @board_type = :unknown
  17. @company_slug = nil
  18. @job_id = nil
  19. @html_content = nil
  20. @cleaned_html = nil
  21. @fetch_mode = "static"
  22. end
  23. def confidence_threshold
  24. CONFIDENCE_THRESHOLD
  25. end
  26. end
  27. end
  28. end

app/services/scraping/orchestration/runner.rb

0.0% lines covered

100.0% branches covered

40 relevant lines. 0 lines covered and 40 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. # Runs the scraping pipeline steps in order.
  5. class Runner
  6. def initialize(context)
  7. @context = context
  8. end
  9. # @return [Boolean] true if completed successfully
  10. def call
  11. steps.each do |step|
  12. outcome = step.call(@context)
  13. return true if outcome == :stop_success
  14. return false if outcome == :stop_failure
  15. end
  16. false
  17. rescue => e
  18. Support::AttemptLifecycle.log_error(@context, "Orchestration failed", e)
  19. @context.event_recorder&.record_failure(
  20. message: e.message,
  21. error_type: e.class.name,
  22. details: { backtrace: e.backtrace&.first(5) }
  23. )
  24. Support::AttemptLifecycle.fail!(@context, failed_step: "orchestration", error_message: e.message)
  25. raise
  26. end
  27. private
  28. def steps
  29. [
  30. Steps::DetectJobBoard.new,
  31. Steps::FetchHtml.new,
  32. Steps::ResolveEmbeddedJobBoard.new,
  33. Steps::RenderedFallback.new,
  34. Steps::HandleLimitedSources.new,
  35. Steps::NokogiriScrape.new,
  36. Steps::SelectorsExtract.new,
  37. Steps::ApiExtract.new,
  38. Steps::AiExtract.new
  39. ]
  40. end
  41. end
  42. end
  43. end

app/services/scraping/orchestration/steps/ai_extract.rb

0.0% lines covered

100.0% branches covered

91 relevant lines. 0 lines covered and 91 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "timeout"
  3. module Scraping
  4. module Orchestration
  5. module Steps
  6. class AiExtract < BaseStep
  7. # Hard timeout for AI extraction to prevent indefinite hangs
  8. # LLM API calls can take 30-60 seconds for large documents
  9. AI_EXTRACTION_TIMEOUT_SECONDS = 120
  10. # Custom error for AI extraction timeout
  11. class AiExtractionTimeoutError < StandardError; end
  12. def call(context)
  13. context.attempt.start_extract!
  14. ai_result = context.event_recorder.record(
  15. :ai_extraction,
  16. input: { html_size: context.html_content&.bytesize, cleaned_html_size: context.cleaned_html&.bytesize }
  17. ) do |event|
  18. # Wrap AI extraction in a timeout to prevent indefinite hangs
  19. result = Timeout.timeout(AI_EXTRACTION_TIMEOUT_SECONDS, AiExtractionTimeoutError) do
  20. Scraping::AiJobExtractorService.new(context.job_listing, scraping_attempt: context.attempt).extract(
  21. html_content: context.html_content,
  22. cleaned_html: context.cleaned_html
  23. )
  24. end
  25. event.set_output(
  26. success: result[:confidence].present? && result[:confidence] >= context.confidence_threshold,
  27. confidence: result[:confidence],
  28. provider: result[:provider],
  29. model: result[:model],
  30. tokens_used: result[:tokens_used],
  31. extracted_fields: result.keys.select { |k| result[k].present? },
  32. error: result[:error]
  33. )
  34. result
  35. end
  36. confidence = ai_result&.dig(:confidence) || 0.0
  37. has_useful_data = ai_result && (ai_result[:title].present? || ai_result[:company].present? || ai_result[:description].present?)
  38. if confidence >= context.confidence_threshold
  39. # High confidence - full success
  40. context.event_recorder.record_simple(:data_update, status: :success, input: { source: "ai" }, output: { confidence: confidence })
  41. Support::JobListingUpdater.update_final!(context, ai_result.merge(extraction_method: "ai"))
  42. context.event_recorder.record_completion(summary: { method: "ai", confidence: confidence, provider: ai_result[:provider], model: ai_result[:model] })
  43. Support::AttemptLifecycle.complete!(
  44. context,
  45. extraction_method: "ai",
  46. provider: ai_result[:provider],
  47. confidence: confidence,
  48. model: ai_result[:model],
  49. tokens_used: ai_result[:tokens_used]
  50. )
  51. return stop_success
  52. end
  53. # Low confidence - but still save whatever useful data we extracted
  54. # This ensures title/company are updated even if overall confidence is low
  55. if has_useful_data
  56. context.event_recorder.record_simple(:data_update, status: :success, input: { source: "ai", partial: true }, output: { confidence: confidence })
  57. Support::JobListingUpdater.update_final!(context, ai_result.merge(extraction_method: "ai"))
  58. Rails.logger.info({
  59. event: "low_confidence_data_saved",
  60. job_listing_id: context.job_listing.id,
  61. confidence: confidence,
  62. extracted_fields: ai_result.keys.select { |k| ai_result[k].present? }
  63. }.to_json)
  64. end
  65. context.event_recorder.record_failure(
  66. message: "Low confidence: #{confidence}",
  67. error_type: "low_confidence",
  68. details: { confidence: confidence, data_saved: has_useful_data }
  69. )
  70. Support::AttemptLifecycle.fail!(context, failed_step: "ai_extraction", error_message: "Low confidence: #{confidence}")
  71. stop_failure
  72. rescue AiExtractionTimeoutError => e
  73. Rails.logger.error("AI extraction timed out after #{AI_EXTRACTION_TIMEOUT_SECONDS}s for job_listing=#{context.job_listing.id}")
  74. notify_error(
  75. e,
  76. context: "ai_extraction_timeout",
  77. severity: "warning",
  78. url: context.job_listing.url,
  79. job_listing_id: context.job_listing.id,
  80. timeout_seconds: AI_EXTRACTION_TIMEOUT_SECONDS
  81. )
  82. Support::AttemptLifecycle.fail!(context, failed_step: "ai_extraction", error_message: "AI extraction timed out after #{AI_EXTRACTION_TIMEOUT_SECONDS} seconds")
  83. stop_failure
  84. rescue => e
  85. Support::AttemptLifecycle.log_error(context, "AI extraction failed", e)
  86. notify_error(
  87. e,
  88. context: "ai_extraction",
  89. severity: "error",
  90. url: context.job_listing.url,
  91. job_listing_id: context.job_listing.id
  92. )
  93. Support::AttemptLifecycle.fail!(context, failed_step: "ai_extraction", error_message: e.message)
  94. stop_failure
  95. end
  96. end
  97. end
  98. end
  99. end

app/services/scraping/orchestration/steps/api_extract.rb

0.0% lines covered

100.0% branches covered

106 relevant lines. 0 lines covered and 106 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Steps
  5. class ApiExtract < BaseStep
  6. def call(context)
  7. detector = context.detector
  8. return continue unless detector&.api_supported? && context.company_slug.present?
  9. unless api_population_enabled_for?(context)
  10. context.event_recorder.record_skipped(:api_extraction, reason: "api_population_disabled", metadata: { board_type: context.board_type })
  11. return continue
  12. end
  13. api_result = context.event_recorder.record(
  14. :api_extraction,
  15. input: { board_type: context.board_type, company_slug: context.company_slug, job_id: context.job_id }
  16. ) do |event|
  17. result = fetch_api(context)
  18. if result
  19. event.set_output(
  20. success: result[:confidence].present? && result[:confidence] >= context.confidence_threshold,
  21. confidence: result[:confidence],
  22. provider: context.board_type,
  23. extracted_fields: result.keys
  24. )
  25. else
  26. event.set_output(success: false, error: "No result from API")
  27. end
  28. result
  29. end
  30. return continue unless api_result && api_result[:confidence] && api_result[:confidence] >= context.confidence_threshold
  31. api_result = maybe_postprocess_with_ai(context, api_result)
  32. context.event_recorder.record_simple(:data_update, status: :success, input: { source: "api" }, output: { confidence: api_result[:confidence] })
  33. Support::JobListingUpdater.update_final!(context, api_result.merge(extraction_method: "api"))
  34. context.event_recorder.record_completion(summary: { method: "api", confidence: api_result[:confidence], provider: context.board_type })
  35. Support::AttemptLifecycle.complete!(context, extraction_method: "api", provider: context.board_type.to_s, confidence: api_result[:confidence], model: api_result[:model], tokens_used: api_result[:tokens_used])
  36. stop_success
  37. rescue => e
  38. Support::AttemptLifecycle.log_error(context, "API extraction failed", e)
  39. notify_error(
  40. e,
  41. context: "api_extraction",
  42. severity: "error",
  43. board_type: context.board_type,
  44. company_slug: context.company_slug,
  45. job_id: context.job_id,
  46. url: context.job_listing.url
  47. )
  48. continue
  49. end
  50. private
  51. def fetch_api(context)
  52. fetcher = case context.board_type.to_sym
  53. when :greenhouse
  54. ApiFetchers::GreenhouseFetcher.new
  55. when :lever
  56. ApiFetchers::LeverFetcher.new
  57. else
  58. nil
  59. end
  60. return nil unless fetcher
  61. fetcher.fetch(url: context.job_listing.url, company_slug: context.company_slug, job_id: context.job_id)
  62. end
  63. def api_population_enabled_for?(context)
  64. # Greenhouse "boards API" is public (no API key) and should be allowed even when
  65. # generic API population is disabled due to missing credentials.
  66. return true if context.board_type.to_s == "greenhouse" && Setting.greenhouse_enabled?
  67. Setting.api_population_enabled?
  68. end
  69. def maybe_postprocess_with_ai(context, api_result)
  70. return api_result unless context.board_type.to_s == "greenhouse"
  71. return api_result unless api_result[:description].present?
  72. # Only run if we have gaps that the LLM can fill (compensation/interview process/lists).
  73. missing_salary = api_result[:salary_min].blank? && api_result[:salary_max].blank?
  74. missing_lists = api_result[:requirements].blank? && api_result[:responsibilities].blank?
  75. likely_has_comp = api_result[:description].to_s.match?(/compensation|salary|usd|eur|\$\s*\d/i)
  76. return api_result unless missing_salary || missing_lists || likely_has_comp
  77. post = Scraping::AiJobPostProcessorService.new(context.job_listing, scraping_attempt: context.attempt).run(
  78. content_html: api_result[:description],
  79. url: context.job_listing.url
  80. )
  81. return api_result if post[:confidence].to_f <= 0.0
  82. custom_sections = (api_result[:custom_sections] || {}).merge(
  83. "job_markdown" => post[:job_markdown].presence,
  84. "compensation_text" => post[:compensation_text].presence,
  85. "interview_process" => post[:interview_process].presence
  86. ).compact
  87. {
  88. **api_result,
  89. salary_min: post[:salary_min].presence || api_result[:salary_min],
  90. salary_max: post[:salary_max].presence || api_result[:salary_max],
  91. salary_currency: post[:salary_currency].presence || api_result[:salary_currency],
  92. requirements: bullets_to_text(post[:requirements_bullets]).presence || api_result[:requirements],
  93. responsibilities: bullets_to_text(post[:responsibilities_bullets]).presence || api_result[:responsibilities],
  94. benefits: bullets_to_text(post[:benefits_bullets]).presence || api_result[:benefits],
  95. perks: bullets_to_text(post[:perks_bullets]).presence || api_result[:perks],
  96. custom_sections: custom_sections
  97. }
  98. rescue => e
  99. Support::AttemptLifecycle.log_error(context, "AI postprocess skipped", e)
  100. api_result
  101. end
  102. def bullets_to_text(items)
  103. arr = Array(items).map(&:to_s).map(&:strip).reject(&:blank?)
  104. return "" if arr.empty?
  105. arr.map { |i| "- #{i}" }.join("\n")
  106. end
  107. end
  108. end
  109. end
  110. end

app/services/scraping/orchestration/steps/base_step.rb

0.0% lines covered

100.0% branches covered

21 relevant lines. 0 lines covered and 21 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Steps
  5. class BaseStep < ApplicationService
  6. def call(_context)
  7. raise NotImplementedError
  8. end
  9. private
  10. def continue
  11. :continue
  12. end
  13. def stop_success
  14. :stop_success
  15. end
  16. def stop_failure
  17. :stop_failure
  18. end
  19. end
  20. end
  21. end
  22. end

app/services/scraping/orchestration/steps/detect_job_board.rb

0.0% lines covered

100.0% branches covered

27 relevant lines. 0 lines covered and 27 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Steps
  5. class DetectJobBoard < BaseStep
  6. def call(context)
  7. detector = Scraping::JobBoardDetectorService.new(context.job_listing.url)
  8. context.detector = detector
  9. context.board_type = detector.detect
  10. context.company_slug = detector.company_slug
  11. context.job_id = detector.job_id
  12. context.event_recorder.record_simple(
  13. :job_board_detection,
  14. status: :success,
  15. input: { url: context.job_listing.url },
  16. output: {
  17. board_type: context.board_type,
  18. company_slug: context.company_slug,
  19. job_id: context.job_id,
  20. api_supported: detector.api_supported?
  21. }
  22. )
  23. continue
  24. end
  25. end
  26. end
  27. end
  28. end

app/services/scraping/orchestration/steps/fetch_html.rb

0.0% lines covered

100.0% branches covered

32 relevant lines. 0 lines covered and 32 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Steps
  5. class FetchHtml < BaseStep
  6. def call(context)
  7. context.attempt.start_fetch!
  8. html_result = context.event_recorder.record(:html_fetch, input: { url: context.job_listing.url }) do |event|
  9. result = Scraping::HtmlFetcherService.new(context.job_listing, scraping_attempt: context.attempt).call
  10. event.set_output(
  11. success: result[:success],
  12. html_size: result[:html_content]&.bytesize,
  13. cleaned_html_size: result[:cleaned_html]&.bytesize,
  14. cached: result[:from_cache],
  15. http_status: result[:http_status],
  16. error: result[:error]
  17. )
  18. result
  19. end
  20. unless html_result[:success]
  21. context.event_recorder.record_failure(message: html_result[:error], error_type: "html_fetch_failed")
  22. Support::AttemptLifecycle.fail!(context, failed_step: "html_fetch", error_message: html_result[:error])
  23. return stop_failure
  24. end
  25. context.html_content = html_result[:html_content]
  26. context.cleaned_html = html_result[:cleaned_html]
  27. context.fetch_mode = "static"
  28. continue
  29. end
  30. end
  31. end
  32. end
  33. end

app/services/scraping/orchestration/steps/handle_limited_sources.rb

0.0% lines covered

100.0% branches covered

125 relevant lines. 0 lines covered and 125 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Steps
  5. # Handles job boards with limited extraction capability
  6. #
  7. # For sources like LinkedIn, Indeed, Glassdoor that require authentication
  8. # or heavily block scraping, this step extracts what's publicly available
  9. # (mainly meta tags) and marks the extraction as limited.
  10. class HandleLimitedSources < BaseStep
  11. LIMITED_BOARDS = %i[linkedin indeed glassdoor].freeze
  12. def call(context)
  13. return continue unless limited_board?(context)
  14. context.event_recorder.record(
  15. :limited_source_handling,
  16. input: { board_type: context.board_type, url: context.job_listing.url }
  17. ) do |event|
  18. result = extract_meta_tags(context)
  19. context.limited_extraction = true
  20. # Update job listing with limited data
  21. if result.any?
  22. update_job_listing(context, result)
  23. event.set_output(
  24. extracted_fields: result.keys,
  25. extraction_quality: "limited",
  26. title: result[:title],
  27. company: result[:company],
  28. description_preview: result[:description]&.truncate(100)
  29. )
  30. else
  31. event.set_output(
  32. extracted_fields: [],
  33. extraction_quality: "limited",
  34. reason: "No meta tags found"
  35. )
  36. end
  37. result
  38. end
  39. # For LinkedIn, we still try full scraping but with lower expectations
  40. # The AI extraction step will handle whatever HTML we can get
  41. continue
  42. end
  43. private
  44. def limited_board?(context)
  45. LIMITED_BOARDS.include?(context.board_type)
  46. end
  47. # Extracts information from meta tags (og:*, twitter:*, etc.)
  48. # @return [Hash] Extracted data
  49. def extract_meta_tags(context)
  50. return {} if context.html_content.blank?
  51. doc = Nokogiri::HTML(context.html_content)
  52. result = {}
  53. # Open Graph tags
  54. og_title = doc.at('meta[property="og:title"]')&.[]("content")
  55. og_description = doc.at('meta[property="og:description"]')&.[]("content")
  56. og_image = doc.at('meta[property="og:image"]')&.[]("content")
  57. og_site_name = doc.at('meta[property="og:site_name"]')&.[]("content")
  58. # Twitter cards
  59. twitter_title = doc.at('meta[name="twitter:title"]')&.[]("content")
  60. twitter_description = doc.at('meta[name="twitter:description"]')&.[]("content")
  61. # Standard meta tags
  62. meta_title = doc.at("title")&.text
  63. meta_description = doc.at('meta[name="description"]')&.[]("content")
  64. # LinkedIn specific - they often have schema.org data
  65. schema_data = extract_schema_org(doc)
  66. # Build result with best available data
  67. result[:title] = parse_linkedin_title(og_title || twitter_title || meta_title || schema_data[:title])
  68. result[:company] = og_site_name || schema_data[:company]
  69. result[:description] = og_description || twitter_description || meta_description || schema_data[:description]
  70. result[:logo_url] = og_image if og_image&.include?("logo")
  71. result[:location] = schema_data[:location]
  72. result.compact
  73. end
  74. # Parses LinkedIn title which often includes " | Company" suffix
  75. def parse_linkedin_title(title)
  76. return nil if title.blank?
  77. # Remove " | LinkedIn" suffix
  78. title = title.gsub(/\s*\|\s*LinkedIn\s*$/i, "")
  79. # Split on " at " or " - " to separate title from company
  80. if title.include?(" at ")
  81. title.split(" at ").first&.strip
  82. elsif title.include?(" - ")
  83. title.split(" - ").first&.strip
  84. else
  85. title.strip
  86. end
  87. end
  88. # Extracts data from JSON-LD schema.org markup
  89. def extract_schema_org(doc)
  90. result = {}
  91. doc.css('script[type="application/ld+json"]').each do |script|
  92. data = JSON.parse(script.text) rescue nil
  93. next unless data
  94. # Handle arrays of schemas
  95. schemas = data.is_a?(Array) ? data : [ data ]
  96. schemas.each do |schema|
  97. next unless schema.is_a?(Hash)
  98. if schema["@type"] == "JobPosting"
  99. result[:title] ||= schema["title"]
  100. result[:description] ||= schema["description"]
  101. result[:company] ||= schema.dig("hiringOrganization", "name")
  102. result[:location] ||= schema.dig("jobLocation", "address", "addressLocality")
  103. result[:salary_min] ||= schema.dig("baseSalary", "value", "minValue")
  104. result[:salary_max] ||= schema.dig("baseSalary", "value", "maxValue")
  105. end
  106. end
  107. end
  108. result
  109. end
  110. def update_job_listing(context, result)
  111. job_listing = context.job_listing
  112. updates = {}
  113. updates[:title] = result[:title] if result[:title].present? && job_listing.title.blank?
  114. # Store extraction metadata
  115. scraped_data = job_listing.scraped_data || {}
  116. scraped_data["job_board"] = context.board_type.to_s
  117. scraped_data["extraction_quality"] = "limited"
  118. scraped_data["limited_extraction_reason"] = limited_extraction_reason(context.board_type)
  119. scraped_data["meta_extraction"] = result
  120. updates[:scraped_data] = scraped_data
  121. job_listing.update!(updates) if updates.any?
  122. # Try to find/create company if we have a name and current company is placeholder
  123. if result[:company].present? && placeholder_company?(job_listing.company)
  124. company = Company.find_or_create_by!(name: result[:company])
  125. job_listing.update!(company: company)
  126. end
  127. end
  128. def placeholder_company?(company)
  129. return true if company.nil?
  130. placeholder_names = [ "unknown company", "unknown" ]
  131. placeholder_names.include?(company.name.to_s.downcase)
  132. end
  133. def limited_extraction_reason(board_type)
  134. case board_type
  135. when :linkedin
  136. "LinkedIn requires authentication for full job details"
  137. when :indeed
  138. "Indeed limits public access to job content"
  139. when :glassdoor
  140. "Glassdoor requires authentication for full job details"
  141. else
  142. "Source has limited public access"
  143. end
  144. end
  145. end
  146. end
  147. end
  148. end

app/services/scraping/orchestration/steps/nokogiri_scrape.rb

0.0% lines covered

100.0% branches covered

36 relevant lines. 0 lines covered and 36 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Steps
  5. class NokogiriScrape < BaseStep
  6. def call(context)
  7. scraping_result = context.event_recorder.record(
  8. :nokogiri_scrape,
  9. input: { html_size: context.html_content&.bytesize }
  10. ) do |event|
  11. extractor = Scraping::HtmlScrapingService.new(
  12. job_listing: context.job_listing,
  13. scraping_attempt: context.attempt,
  14. board_type: context.board_type&.to_s,
  15. fetch_mode: context.fetch_mode,
  16. extractor_kind: "generic_html_scraping",
  17. run_context: "orchestrator"
  18. )
  19. result = extractor.extract(context.html_content, context.job_listing.url)
  20. event.set_output(
  21. extracted_fields: result.keys,
  22. title: result[:title],
  23. company: result[:company_name],
  24. location: result[:location]
  25. )
  26. result
  27. end
  28. Support::JobListingUpdater.update_preliminary!(context, scraping_result) if scraping_result.any?
  29. continue
  30. rescue => e
  31. Support::AttemptLifecycle.log_error(context, "HTML scraping failed", e)
  32. continue
  33. end
  34. end
  35. end
  36. end
  37. end

app/services/scraping/orchestration/steps/rendered_fallback.rb

0.0% lines covered

100.0% branches covered

80 relevant lines. 0 lines covered and 80 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Steps
  5. class RenderedFallback < BaseStep
  6. def call(context)
  7. diagnosis = Support::Observability.js_heavy_diagnosis(html_content: context.html_content, cleaned_html: context.cleaned_html)
  8. unless Setting.js_rendering_enabled?
  9. context.event_recorder.record_simple(
  10. :js_heavy_detected,
  11. status: :skipped,
  12. output: diagnosis.merge(
  13. js_rendering_enabled: false,
  14. triggered: false,
  15. skipped_reason: "js_rendering_disabled",
  16. board_type: context.board_type
  17. )
  18. )
  19. return continue
  20. end
  21. unless diagnosis[:js_heavy]
  22. context.event_recorder.record_simple(
  23. :js_heavy_detected,
  24. status: :skipped,
  25. output: diagnosis.merge(
  26. js_rendering_enabled: true,
  27. triggered: false,
  28. skipped_reason: "not_js_heavy",
  29. board_type: context.board_type,
  30. fetch_mode: context.fetch_mode
  31. )
  32. )
  33. return continue
  34. end
  35. context.event_recorder.record_simple(
  36. :js_heavy_detected,
  37. status: :success,
  38. output: {
  39. **diagnosis,
  40. js_rendering_enabled: true,
  41. triggered: true,
  42. board_type: context.board_type,
  43. fetch_mode: context.fetch_mode
  44. }
  45. )
  46. rendered_result = context.event_recorder.record(
  47. :rendered_html_fetch,
  48. input: { url: context.job_listing.url, board_type: context.board_type }
  49. ) do |event|
  50. result = Scraping::RenderedHtmlFetcherService.new(context.job_listing, scraping_attempt: context.attempt).call
  51. rendered_shell =
  52. result[:success] &&
  53. (result[:cleaned_text_length].to_i < 500 || result[:selector_found] != true)
  54. event.set_output(
  55. success: result[:success],
  56. html_size: result[:html_content]&.bytesize,
  57. cleaned_html_size: result[:cleaned_html]&.bytesize,
  58. cleaned_text_length: result[:cleaned_text_length],
  59. error: result[:error],
  60. rendered: true,
  61. fetch_mode: "rendered",
  62. trigger_reason: diagnosis[:reason],
  63. selector_found: result[:selector_found],
  64. found_selectors: result[:found_selectors],
  65. selector_wait_ms: result[:selector_wait_ms],
  66. iframe_used: result[:iframe_used],
  67. rendered_shell: rendered_shell
  68. )
  69. result
  70. end
  71. if rendered_result[:success]
  72. context.html_content = rendered_result[:html_content]
  73. context.cleaned_html = rendered_result[:cleaned_html]
  74. context.fetch_mode = "rendered"
  75. end
  76. continue
  77. end
  78. end
  79. end
  80. end
  81. end

app/services/scraping/orchestration/steps/resolve_embedded_job_board.rb

0.0% lines covered

100.0% branches covered

123 relevant lines. 0 lines covered and 123 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "uri"
  3. module Scraping
  4. module Orchestration
  5. module Steps
  6. # Attempts to resolve embedded job board pages into a fetchable HTML document
  7. # that actually contains job content.
  8. #
  9. # Today this primarily targets Greenhouse "gh_jid" embeds used by many marketing sites
  10. # (WordPress, Webflow, etc.) where the visible page is a shell and the job content
  11. # is served from `job-boards.greenhouse.io`.
  12. class ResolveEmbeddedJobBoard < BaseStep
  13. GREENHOUSE_FOR_REGEX = %r{embed/job_board/js\?for=([a-zA-Z0-9_-]+)}.freeze
  14. def call(context)
  15. return continue unless context.board_type.to_s == "greenhouse"
  16. jid = extract_query_param(context.job_listing.url, "gh_jid")
  17. return continue unless jid.present?
  18. for_key = extract_greenhouse_for_key(context.html_content)
  19. return continue unless for_key.present?
  20. # Enable downstream API extraction (Greenhouse boards API) even for marketing URLs.
  21. context.company_slug ||= for_key
  22. context.job_id ||= jid
  23. embed_url = build_greenhouse_embed_url(for_key: for_key, jid: jid, source: extract_query_param(context.job_listing.url, "gh_src"))
  24. resolved = context.event_recorder.record(
  25. :embedded_job_board_fetch,
  26. input: {
  27. board_type: "greenhouse",
  28. for_key: for_key,
  29. gh_jid: jid,
  30. embed_url: embed_url
  31. }
  32. ) do |event|
  33. result = fetch_html(embed_url, context)
  34. event.set_output(
  35. success: result[:success],
  36. http_status: result[:http_status],
  37. html_size: result[:html_content]&.bytesize,
  38. cleaned_html_size: result[:cleaned_html]&.bytesize,
  39. cleaned_text_length: extracted_text_length(result[:cleaned_html]),
  40. error: result[:error],
  41. fetch_mode: "greenhouse_embed"
  42. )
  43. result
  44. end
  45. return continue unless resolved[:success]
  46. # Only switch if we clearly got "real" content (avoid swapping in another shell).
  47. cleaned_text_length = extracted_text_length(resolved[:cleaned_html])
  48. return continue if cleaned_text_length < 800
  49. context.html_content = resolved[:html_content]
  50. context.cleaned_html = resolved[:cleaned_html]
  51. context.fetch_mode = "greenhouse_embed"
  52. continue
  53. rescue => e
  54. context.event_recorder.record_simple(
  55. :embedded_job_board_fetch,
  56. status: :failed,
  57. output: { error: e.message, error_type: e.class.name }
  58. )
  59. continue
  60. end
  61. private
  62. def extract_greenhouse_for_key(html_content)
  63. html_content.to_s[GREENHOUSE_FOR_REGEX, 1]
  64. end
  65. def build_greenhouse_embed_url(for_key:, jid:, source: nil)
  66. query = { "for" => for_key, "gh_jid" => jid }
  67. query["gh_src"] = source if source.present?
  68. "https://job-boards.greenhouse.io/embed/job_board?#{URI.encode_www_form(query)}"
  69. end
  70. def extract_query_param(url, key)
  71. uri = URI.parse(url)
  72. query = uri.query.to_s
  73. Rack::Utils.parse_query(query)[key]
  74. rescue
  75. nil
  76. end
  77. def fetch_html(url, context)
  78. start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  79. response = HTTParty.get(
  80. url,
  81. headers: {
  82. "User-Agent" => Scraping::RenderedHtmlFetcherService::REALISTIC_UA,
  83. "Accept" => "text/html",
  84. "Accept-Language" => "en-US,en;q=0.9"
  85. },
  86. timeout: 30,
  87. open_timeout: 10,
  88. follow_redirects: true,
  89. max_redirects: 3
  90. )
  91. duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
  92. unless response.success?
  93. return { success: false, error: "HTTP #{response.code}: Failed to fetch embedded HTML", http_status: response.code }
  94. end
  95. html = response.body.to_s
  96. cleaner = Scraping::HtmlCleaners::CleanerFactory.cleaner_for_url(url)
  97. cleaned_html = cleaner.clean(html)
  98. ScrapedJobListingData.create_with_html(
  99. url: url,
  100. html_content: html,
  101. job_listing: context.job_listing,
  102. scraping_attempt: context.attempt,
  103. http_status: response.code,
  104. metadata: {
  105. fetched_via: "http",
  106. fetch_mode: "greenhouse_embed",
  107. rendered: false,
  108. duration_ms: duration_ms,
  109. embedded_from_url: context.job_listing.url
  110. }
  111. )
  112. {
  113. success: true,
  114. html_content: html,
  115. cleaned_html: cleaned_html,
  116. http_status: response.code,
  117. duration_ms: duration_ms
  118. }
  119. rescue Timeout::Error => e
  120. { success: false, error: "Embedded fetch timeout: #{e.message}" }
  121. rescue => e
  122. { success: false, error: "Embedded fetch failed: #{e.message}" }
  123. end
  124. def extracted_text_length(html)
  125. Nokogiri::HTML(html.to_s).text.to_s.strip.length
  126. rescue
  127. 0
  128. end
  129. end
  130. end
  131. end
  132. end

app/services/scraping/orchestration/steps/selectors_extract.rb

0.0% lines covered

100.0% branches covered

47 relevant lines. 0 lines covered and 47 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Steps
  5. class SelectorsExtract < BaseStep
  6. def call(context)
  7. return continue if context.board_type.to_sym == :unknown
  8. selectors_result = context.event_recorder.record(
  9. :selectors_extraction,
  10. input: { board_type: context.board_type }
  11. ) do |event|
  12. extractor = Scraping::JobBoards::ExtractorFactory.build(context.board_type)
  13. result = extractor.extract(context.html_content)
  14. event.set_output(
  15. success: result[:success],
  16. confidence: result[:confidence],
  17. extracted_fields: result[:extracted_fields],
  18. missing_fields: result[:missing_fields],
  19. board_type: result[:board_type],
  20. extractor_kind: result[:extractor_kind]
  21. )
  22. Support::Observability.create_selectors_html_log(
  23. context,
  24. result,
  25. fetch_mode: context.fetch_mode,
  26. board_type: context.board_type,
  27. html_size: context.html_content.to_s.bytesize,
  28. cleaned_html_size: context.cleaned_html.to_s.bytesize
  29. )
  30. result
  31. end
  32. return continue unless selectors_result[:success] && selectors_result[:confidence].to_f >= context.confidence_threshold
  33. data = selectors_result[:data] || {}
  34. result_for_update = data.merge(
  35. extraction_method: "html",
  36. provider: selectors_result[:provider] || context.board_type.to_s,
  37. confidence: selectors_result[:confidence]
  38. )
  39. Support::JobListingUpdater.update_final!(context, result_for_update)
  40. context.event_recorder.record_simple(:data_update, status: :success, input: { source: "selectors" }, output: { confidence: selectors_result[:confidence] })
  41. Support::AttemptLifecycle.complete!(context, extraction_method: "html", provider: context.board_type.to_s, confidence: selectors_result[:confidence])
  42. context.event_recorder.record_completion(summary: { method: "html", confidence: selectors_result[:confidence], provider: context.board_type })
  43. stop_success
  44. end
  45. end
  46. end
  47. end
  48. end

app/services/scraping/orchestration/support/attempt_lifecycle.rb

0.0% lines covered

100.0% branches covered

112 relevant lines. 0 lines covered and 112 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Support
  5. module AttemptLifecycle
  6. module_function
  7. # Creates a new scraping attempt, or returns existing recent one if still in progress
  8. #
  9. # Prevents duplicate attempts when:
  10. # - A timeout occurs and the job is re-queued
  11. # - Multiple calls happen in quick succession
  12. # - An attempt just completed (no need to re-extract)
  13. #
  14. # @param job_listing [JobListing] The job listing to create an attempt for
  15. # @param force [Boolean] Force create even if recent attempt exists
  16. # @return [ScrapingAttempt, nil] New or existing attempt, or nil if recently completed
  17. def create_attempt!(job_listing, force: false)
  18. unless force
  19. # Check for existing recent attempt that's still in progress (within last 2 minutes)
  20. recent_in_progress = job_listing.scraping_attempts
  21. .where(status: [ :pending, :fetching, :extracting, :retrying ])
  22. .where("created_at > ?", 2.minutes.ago)
  23. .order(created_at: :desc)
  24. .first
  25. if recent_in_progress
  26. Rails.logger.info({
  27. event: "reusing_existing_attempt",
  28. job_listing_id: job_listing.id,
  29. scraping_attempt_id: recent_in_progress.id,
  30. status: recent_in_progress.status
  31. }.to_json)
  32. return recent_in_progress
  33. end
  34. # Check for recently completed attempt (within last 2 minutes)
  35. # No need to re-extract if we just finished successfully
  36. recent_completed = job_listing.scraping_attempts
  37. .where(status: :completed)
  38. .where("created_at > ?", 2.minutes.ago)
  39. .order(created_at: :desc)
  40. .first
  41. if recent_completed
  42. Rails.logger.info({
  43. event: "skipping_attempt_recently_completed",
  44. job_listing_id: job_listing.id,
  45. scraping_attempt_id: recent_completed.id,
  46. completed_at: recent_completed.updated_at
  47. }.to_json)
  48. return nil
  49. end
  50. end
  51. job_listing.scraping_attempts.create!(
  52. url: job_listing.url,
  53. domain: extract_domain(job_listing.url),
  54. status: :pending
  55. )
  56. end
  57. def complete!(context, extraction_method:, provider:, confidence:, model: nil, tokens_used: nil)
  58. attempt = context.attempt
  59. return unless attempt
  60. # Ensure state machine is in a completable state.
  61. # Some completions happen via selectors/API without ever hitting the AI step,
  62. # so we may still be in :fetching here.
  63. attempt.start_fetch! if attempt.respond_to?(:may_start_fetch?) && attempt.may_start_fetch?
  64. attempt.start_extract! if attempt.respond_to?(:may_start_extract?) && attempt.may_start_extract?
  65. attempt.update(
  66. extraction_method: extraction_method,
  67. provider: provider,
  68. confidence_score: confidence,
  69. duration_seconds: Time.current - context.started_at,
  70. response_metadata: {
  71. model: model,
  72. tokens_used: tokens_used
  73. }
  74. )
  75. attempt.mark_completed!
  76. log_event(context, "extraction_completed", {
  77. confidence: confidence,
  78. duration: Time.current - context.started_at
  79. })
  80. end
  81. def fail!(context, failed_step:, error_message:)
  82. attempt = context.attempt
  83. return unless attempt
  84. attempt.update(
  85. failed_step: failed_step,
  86. error_message: error_message,
  87. duration_seconds: Time.current - context.started_at
  88. )
  89. attempt.mark_failed!
  90. log_event(context, "extraction_failed", { failed_step: failed_step, error: error_message })
  91. end
  92. def log_event(context, event_name, data = {})
  93. Rails.logger.info({
  94. event: event_name,
  95. job_listing_id: context.job_listing.id,
  96. scraping_attempt_id: context.attempt&.id,
  97. url: context.job_listing.url,
  98. domain: extract_domain(context.job_listing.url)
  99. }.merge(data).to_json)
  100. end
  101. def log_error(context, message, exception)
  102. Rails.logger.error({
  103. error: message,
  104. exception: exception.class.name,
  105. message: exception.message,
  106. backtrace: exception.backtrace&.first(5),
  107. job_listing_id: context.job_listing.id,
  108. scraping_attempt_id: context.attempt&.id,
  109. url: context.job_listing.url
  110. }.to_json)
  111. ApplicationService.new.notify_error(
  112. exception,
  113. context: "scraping_orchestration",
  114. severity: "error",
  115. error_message: message,
  116. job_listing_id: context.job_listing.id,
  117. scraping_attempt_id: context.attempt&.id,
  118. url: context.job_listing.url
  119. )
  120. end
  121. def extract_domain(url)
  122. URI.parse(url).host
  123. rescue
  124. "unknown"
  125. end
  126. end
  127. end
  128. end
  129. end

app/services/scraping/orchestration/support/entity_resolver.rb

0.0% lines covered

100.0% branches covered

185 relevant lines. 0 lines covered and 185 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Support
  5. module EntityResolver
  6. module_function
  7. def find_or_create_company(context, name)
  8. job_listing = context.job_listing
  9. return job_listing.company if name.blank?
  10. normalized_name = normalize_company_name(name)
  11. domain = extract_domain_from_url(job_listing.url)
  12. if job_listing.company.present?
  13. existing_company = job_listing.company
  14. if domain.present? && existing_company.website.present?
  15. existing_domain = extract_domain_from_url(existing_company.website)
  16. return existing_company if domains_match?(domain, existing_domain)
  17. end
  18. existing_normalized = normalize_company_name(existing_company.name)
  19. return existing_company if names_similar?(normalized_name, existing_normalized)
  20. end
  21. if domain.present?
  22. company = find_company_by_domain(domain)
  23. return company if company
  24. end
  25. company = Company.find_by(name: normalized_name)
  26. return company if company
  27. company = find_similar_company(normalized_name)
  28. return company if company
  29. Company.create!(name: normalized_name) do |c|
  30. c.website = "https://#{domain}" if domain.present?
  31. end
  32. end
  33. def find_or_create_job_role(context, title, department_name: nil)
  34. job_listing = context.job_listing
  35. return job_listing.job_role if title.blank?
  36. normalized_title = normalize_job_role_title(title)
  37. job_role = JobRole.find_or_create_by(title: normalized_title)
  38. # Assign department if provided and role doesn't have one
  39. if department_name.present? && job_role.category_id.nil?
  40. department = Category.find_by(name: department_name, kind: :job_role)
  41. department ||= infer_department_from_title(normalized_title)
  42. job_role.update(category: department) if department
  43. elsif job_role.category_id.nil?
  44. # Try to infer department from title if not provided
  45. department = infer_department_from_title(normalized_title)
  46. job_role.update(category: department) if department
  47. end
  48. job_role
  49. end
  50. def infer_department_from_title(title)
  51. return nil if title.blank?
  52. title_lower = title.downcase
  53. department_keywords = {
  54. "Engineering" => %w[engineer developer software backend frontend fullstack architect sre devops platform],
  55. "Product" => %w[product owner manager pm],
  56. "Design" => %w[designer ux ui visual graphic],
  57. "Data Science" => %w[data scientist analyst analytics machine learning ml ai],
  58. "DevOps/SRE" => %w[devops sre infrastructure reliability platform],
  59. "Sales" => %w[sales account executive ae sdr bdr],
  60. "Marketing" => %w[marketing growth seo sem content brand],
  61. "Customer Success" => %w[customer success support cx],
  62. "Finance" => %w[finance accounting financial controller cfo],
  63. "HR/People" => %w[hr human resources people talent recruiter recruiting],
  64. "Legal" => %w[legal counsel attorney compliance],
  65. "Operations" => %w[operations ops logistics supply],
  66. "Executive" => %w[ceo cto coo cfo cmo chief director vp president],
  67. "Research" => %w[research scientist r&d],
  68. "QA/Testing" => %w[qa quality assurance test tester sdet],
  69. "Security" => %w[security infosec appsec cyber],
  70. "IT" => %w[it helpdesk administrator admin sysadmin],
  71. "Content" => %w[content writer editor copywriter]
  72. }
  73. department_keywords.each do |dept_name, keywords|
  74. if keywords.any? { |kw| title_lower.include?(kw) }
  75. return Category.find_by(name: dept_name, kind: :job_role)
  76. end
  77. end
  78. nil
  79. end
  80. def normalize_company_name(name)
  81. return nil if name.blank?
  82. normalized = name.strip
  83. suffixes = [
  84. /\s+inc\.?$/i,
  85. /\s+llc\.?$/i,
  86. /\s+corp\.?$/i,
  87. /\s+corporation$/i,
  88. /\s+ltd\.?$/i,
  89. /\s+limited$/i,
  90. /\s+co\.?$/i,
  91. /\s+company$/i,
  92. /\s+\.io$/i,
  93. /\s+\.com$/i,
  94. /\s+\.net$/i,
  95. /\s+\.org$/i
  96. ]
  97. suffixes.each { |suffix| normalized = normalized.gsub(suffix, "") }
  98. normalized.strip.titleize
  99. end
  100. def normalize_job_role_title(title)
  101. return nil if title.blank?
  102. title.strip
  103. end
  104. def names_similar?(name1, name2)
  105. return false if name1.blank? || name2.blank?
  106. return true if name1.downcase == name2.downcase
  107. n1 = name1.downcase
  108. n2 = name2.downcase
  109. return true if n1.include?(n2) || n2.include?(n1)
  110. # Also compare without spaces (handles "Ever Ai" vs "EverAI")
  111. n1_compact = n1.gsub(/\s+/, "")
  112. n2_compact = n2.gsub(/\s+/, "")
  113. return true if n1_compact == n2_compact
  114. return true if n1_compact.include?(n2_compact) || n2_compact.include?(n1_compact)
  115. distance = levenshtein_distance(n1, n2)
  116. max_distance = [ name1.length, name2.length ].min / 3
  117. distance <= [ max_distance, 2 ].max
  118. end
  119. def levenshtein_distance(str1, str2)
  120. m, n = str1.length, str2.length
  121. return n if m == 0
  122. return m if n == 0
  123. d = Array.new(m + 1) { Array.new(n + 1) }
  124. (0..m).each { |i| d[i][0] = i }
  125. (0..n).each { |j| d[0][j] = j }
  126. (1..n).each do |j|
  127. (1..m).each do |i|
  128. d[i][j] = if str1[i - 1] == str2[j - 1]
  129. d[i - 1][j - 1]
  130. else
  131. [ d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + 1 ].min
  132. end
  133. end
  134. end
  135. d[m][n]
  136. end
  137. def extract_domain_from_url(url)
  138. return nil if url.blank?
  139. uri = URI.parse(url)
  140. domain = uri.host
  141. return nil unless domain
  142. domain = domain.downcase
  143. domain.sub(/^www\./, "")
  144. rescue
  145. nil
  146. end
  147. def normalize_domain(domain)
  148. return "" if domain.blank?
  149. domain = domain.gsub(/^https?:\/\//, "")
  150. domain = domain.split("/").first
  151. domain = domain.downcase
  152. domain.sub(/^www\./, "")
  153. end
  154. def domains_match?(domain1, domain2)
  155. return false if domain1.blank? || domain2.blank?
  156. norm1 = normalize_domain(domain1)
  157. norm2 = normalize_domain(domain2)
  158. return true if norm1 == norm2
  159. return true if norm1.end_with?(".#{norm2}") || norm2.end_with?(".#{norm1}")
  160. parts1 = norm1.split(".")
  161. parts2 = norm2.split(".")
  162. if parts1.length >= 2 && parts2.length >= 2
  163. base1 = parts1[-2..-1].join(".")
  164. base2 = parts2[-2..-1].join(".")
  165. return true if base1 == base2
  166. end
  167. false
  168. end
  169. def find_company_by_domain(domain)
  170. return nil if domain.blank?
  171. normalized = normalize_domain(domain)
  172. Company.where.not(website: nil).find_each do |company|
  173. company_domain = extract_domain_from_url(company.website)
  174. return company if company_domain.present? && domains_match?(normalized, company_domain)
  175. end
  176. nil
  177. end
  178. def find_similar_company(normalized_name)
  179. return nil if normalized_name.blank?
  180. Company.find_each do |company|
  181. existing_normalized = normalize_company_name(company.name)
  182. return company if names_similar?(normalized_name, existing_normalized)
  183. end
  184. nil
  185. end
  186. end
  187. end
  188. end
  189. end

app/services/scraping/orchestration/support/job_listing_updater.rb

0.0% lines covered

100.0% branches covered

96 relevant lines. 0 lines covered and 96 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Support
  5. module JobListingUpdater
  6. module_function
  7. def update_preliminary!(context, preliminary_data)
  8. job_listing = context.job_listing
  9. updates = {}
  10. updates[:title] = preliminary_data[:title] if preliminary_data[:title].present? && job_listing.title.blank?
  11. updates[:location] = preliminary_data[:location] if preliminary_data[:location].present? && job_listing.location.blank?
  12. updates[:remote_type] = preliminary_data[:remote_type] if preliminary_data[:remote_type].present? && job_listing.remote_type == "on_site"
  13. updates[:salary_min] = preliminary_data[:salary_min] if preliminary_data[:salary_min].present? && job_listing.salary_min.blank?
  14. updates[:salary_max] = preliminary_data[:salary_max] if preliminary_data[:salary_max].present? && job_listing.salary_max.blank?
  15. updates[:salary_currency] = preliminary_data[:salary_currency] if preliminary_data[:salary_currency].present?
  16. updates[:description] = preliminary_data[:description] if preliminary_data[:description].present? && job_listing.description.blank?
  17. updates[:about_company] = preliminary_data[:about_company] if preliminary_data[:about_company].present? && job_listing.about_company.blank?
  18. updates[:company_culture] = preliminary_data[:company_culture] if preliminary_data[:company_culture].present? && job_listing.company_culture.blank?
  19. if preliminary_data[:company_name].present?
  20. company = EntityResolver.find_or_create_company(context, preliminary_data[:company_name])
  21. updates[:company] = company if job_listing.company_id.nil? || company.id != job_listing.company_id
  22. end
  23. job_role_title = preliminary_data[:job_role_title] || preliminary_data[:title]
  24. if job_role_title.present?
  25. department_name = preliminary_data[:job_role_department]
  26. job_role = EntityResolver.find_or_create_job_role(context, job_role_title, department_name: department_name)
  27. updates[:job_role] = job_role if job_listing.job_role_id.nil? || job_role.id != job_listing.job_role_id
  28. end
  29. return false if updates.empty?
  30. job_listing.update(updates)
  31. end
  32. # Placeholder values that should always be replaced with real extracted data
  33. PLACEHOLDER_COMPANY_NAMES = [ "Unknown Company", "Unknown" ].freeze
  34. PLACEHOLDER_JOB_ROLES = [ "Unknown Position", "Unknown Role", "Unknown" ].freeze
  35. def update_final!(context, result)
  36. job_listing = context.job_listing
  37. # Merge custom_sections to preserve existing data while adding new fields
  38. merged_custom_sections = (job_listing.custom_sections || {}).merge(result[:custom_sections] || {})
  39. updates = {
  40. title: result[:title] || job_listing.title,
  41. description: result[:description] || job_listing.description,
  42. about_company: result[:about_company] || job_listing.about_company,
  43. company_culture: result[:company_culture] || job_listing.company_culture,
  44. requirements: result[:requirements] || job_listing.requirements,
  45. responsibilities: result[:responsibilities] || job_listing.responsibilities,
  46. salary_min: result[:salary_min] || job_listing.salary_min,
  47. salary_max: result[:salary_max] || job_listing.salary_max,
  48. salary_currency: result[:salary_currency] || job_listing.salary_currency,
  49. equity_info: result[:equity_info] || job_listing.equity_info,
  50. benefits: result[:benefits] || job_listing.benefits,
  51. perks: result[:perks] || job_listing.perks,
  52. location: result[:location] || job_listing.location,
  53. remote_type: result[:remote_type] || job_listing.remote_type,
  54. custom_sections: merged_custom_sections,
  55. scraped_data: build_scraped_metadata(context, result)
  56. }
  57. # Update company if we have extracted data and current is nil/placeholder
  58. company_name = result[:company] || result[:company_name]
  59. if company_name.present?
  60. company = EntityResolver.find_or_create_company(context, company_name)
  61. should_update_company = job_listing.company_id.nil? ||
  62. company.id != job_listing.company_id ||
  63. is_placeholder_company?(job_listing.company)
  64. updates[:company] = company if should_update_company
  65. end
  66. # Update job role using title as fallback, replacing placeholders
  67. job_role_title = result[:job_role] || result[:title]
  68. if job_role_title.present?
  69. department_name = result[:job_role_department]
  70. job_role = EntityResolver.find_or_create_job_role(context, job_role_title, department_name: department_name)
  71. should_update_role = job_listing.job_role_id.nil? ||
  72. job_role.id != job_listing.job_role_id ||
  73. is_placeholder_job_role?(job_listing.job_role)
  74. updates[:job_role] = job_role if should_update_role
  75. end
  76. job_listing.update(updates)
  77. end
  78. def is_placeholder_company?(company)
  79. return true if company.nil?
  80. PLACEHOLDER_COMPANY_NAMES.any? { |placeholder| company.name&.downcase&.include?(placeholder.downcase) }
  81. end
  82. def is_placeholder_job_role?(job_role)
  83. return true if job_role.nil?
  84. PLACEHOLDER_JOB_ROLES.any? { |placeholder| job_role.title&.downcase&.include?(placeholder.downcase) }
  85. end
  86. def build_scraped_metadata(context, result)
  87. {
  88. status: "completed",
  89. extraction_method: result[:extraction_method] || "ai",
  90. provider: result[:provider],
  91. model: result[:model],
  92. confidence_score: result[:confidence],
  93. tokens_used: result[:tokens_used],
  94. extracted_at: Time.current.iso8601,
  95. duration_seconds: Time.current - context.started_at
  96. }
  97. end
  98. end
  99. end
  100. end
  101. end

app/services/scraping/orchestration/support/observability.rb

0.0% lines covered

100.0% branches covered

86 relevant lines. 0 lines covered and 86 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. module Orchestration
  4. module Support
  5. module Observability
  6. module_function
  7. JS_HEAVY_TEXT_THRESHOLD = 1500
  8. # Returns a diagnosis for whether a page appears JS-heavy, along with the signals used.
  9. #
  10. # @param html_content [String, nil]
  11. # @param cleaned_html [String, nil]
  12. # @return [Hash]
  13. def js_heavy_diagnosis(html_content:, cleaned_html:)
  14. text_len = cleaned_html.to_s.length
  15. html = html_content.to_s
  16. spa_markers = [
  17. "__NEXT_DATA__",
  18. "data-reactroot",
  19. "id=\"app\"",
  20. "id=\"root\""
  21. ]
  22. found_markers = spa_markers.select { |m| html.include?(m) }
  23. js_heavy =
  24. if text_len >= JS_HEAVY_TEXT_THRESHOLD
  25. false
  26. else
  27. found_markers.any? || text_len < 200
  28. end
  29. reason =
  30. if text_len >= JS_HEAVY_TEXT_THRESHOLD
  31. "text_above_threshold"
  32. elsif found_markers.any?
  33. "spa_marker_detected"
  34. elsif text_len < 200
  35. "very_low_text"
  36. else
  37. "below_threshold"
  38. end
  39. {
  40. js_heavy: js_heavy,
  41. reason: reason,
  42. text_length: text_len,
  43. threshold: JS_HEAVY_TEXT_THRESHOLD,
  44. html_size: html.bytesize,
  45. spa_markers_found: found_markers
  46. }
  47. rescue => e
  48. {
  49. js_heavy: false,
  50. reason: "diagnosis_error",
  51. error: e.message
  52. }
  53. end
  54. def js_heavy_page?(html_content:, cleaned_html:)
  55. js_heavy_diagnosis(html_content: html_content, cleaned_html: cleaned_html)[:js_heavy]
  56. end
  57. def create_selectors_html_log(context, selectors_result, fetch_mode:, board_type:, html_size:, cleaned_html_size:)
  58. HtmlScrapingLog.create!(
  59. scraping_attempt: context.attempt,
  60. job_listing: context.job_listing,
  61. url: context.job_listing.url,
  62. domain: AttemptLifecycle.extract_domain(context.job_listing.url),
  63. html_size: html_size,
  64. cleaned_html_size: cleaned_html_size,
  65. duration_ms: nil,
  66. field_results: build_field_results_from_selectors(selectors_result),
  67. selectors_tried: selectors_result[:selectors_tried] || {},
  68. fetch_mode: fetch_mode,
  69. board_type: board_type.to_s,
  70. extractor_kind: selectors_result[:extractor_kind] || "job_board_selectors",
  71. run_context: "orchestrator"
  72. )
  73. rescue => e
  74. Rails.logger.warn("Failed to create HtmlScrapingLog for selectors extraction: #{e.message}")
  75. nil
  76. end
  77. def build_field_results_from_selectors(selectors_result)
  78. data = selectors_result[:data] || {}
  79. (HtmlScrapingLog::TRACKED_FIELDS + [ "job_role_title" ]).uniq.each_with_object({}) do |field, hash|
  80. key = field.to_s
  81. value = data[key.to_sym] || data[key]
  82. hash[key] = {
  83. "success" => value.present?,
  84. "value" => value.to_s.truncate(500),
  85. "selectors_tried" => Array(selectors_result.dig(:selectors_tried, key))
  86. }
  87. end
  88. end
  89. end
  90. end
  91. end
  92. end

app/services/scraping/orchestrator_service.rb

0.0% lines covered

100.0% branches covered

30 relevant lines. 0 lines covered and 30 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Backwards-compatible entrypoint for job listing extraction.
  4. #
  5. # The actual pipeline lives under Scraping::Orchestration.
  6. class OrchestratorService
  7. attr_reader :job_listing, :attempt, :event_recorder
  8. def initialize(job_listing)
  9. @job_listing = job_listing
  10. @attempt = nil
  11. @event_recorder = nil
  12. end
  13. # @return [Boolean] true if extraction completed successfully
  14. def call
  15. return false unless job_listing.url.present?
  16. job_listing.save! if job_listing.new_record?
  17. @attempt = Scraping::Orchestration::Support::AttemptLifecycle.create_attempt!(job_listing)
  18. # If create_attempt! returns nil, a recent attempt just completed - no need to re-extract
  19. if @attempt.nil?
  20. Rails.logger.info({
  21. event: "extraction_skipped_recent_completion",
  22. job_listing_id: job_listing.id
  23. }.to_json)
  24. return true
  25. end
  26. @event_recorder = Scraping::EventRecorderService.new(@attempt, job_listing: job_listing)
  27. context = Scraping::Orchestration::Context.new(
  28. job_listing: job_listing,
  29. attempt: @attempt,
  30. event_recorder: @event_recorder
  31. )
  32. Scraping::Orchestration::Support::AttemptLifecycle.log_event(context, "extraction_started")
  33. Scraping::Orchestration::Runner.new(context).call
  34. end
  35. end
  36. end

app/services/scraping/rate_limiter_service.rb

0.0% lines covered

100.0% branches covered

50 relevant lines. 0 lines covered and 50 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Service for rate limiting requests per domain
  4. #
  5. # Uses Rails.cache to track request timestamps and enforce rate limits
  6. # to avoid overwhelming job board servers.
  7. #
  8. # @example
  9. # limiter = Scraping::RateLimiterService.new("linkedin.com")
  10. # if limiter.allowed?
  11. # # Make request
  12. # limiter.record_request!
  13. # else
  14. # sleep limiter.wait_time
  15. # end
  16. class RateLimiterService
  17. # Initialize the rate limiter for a domain
  18. #
  19. # @param [String] domain The domain to rate limit
  20. def initialize(domain)
  21. @domain = domain
  22. @cache_key = "rate_limit:#{domain}"
  23. @cache = if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
  24. ActiveSupport::Cache::MemoryStore.new
  25. else
  26. Rails.cache
  27. end
  28. end
  29. # Checks if a request to this domain is allowed
  30. #
  31. # @return [Boolean] True if request can be made now
  32. def allowed?
  33. last_request_time = cache.read(@cache_key)
  34. return true if last_request_time.nil?
  35. time_since_last = Time.current - last_request_time
  36. time_since_last >= rate_limit_seconds
  37. end
  38. # Records a request timestamp for this domain
  39. #
  40. # @return [Boolean] True if successfully recorded
  41. def record_request!
  42. cache.write(@cache_key, Time.current, expires_in: 1.hour)
  43. end
  44. # Returns the wait time before next request is allowed
  45. #
  46. # @return [Float] Seconds to wait, 0 if can request now
  47. def wait_time
  48. return 0.0 if allowed?
  49. last_request_time = cache.read(@cache_key)
  50. return 0.0 if last_request_time.nil?
  51. time_since_last = Time.current - last_request_time
  52. remaining = rate_limit_seconds - time_since_last
  53. [ remaining, 0.0 ].max
  54. end
  55. # Blocks until the domain is ready for another request
  56. #
  57. # @return [void]
  58. def wait_if_needed!
  59. wait_seconds = wait_time
  60. sleep(wait_seconds) if wait_seconds > 0
  61. end
  62. private
  63. def cache
  64. @cache
  65. end
  66. # Returns the rate limit in seconds for this domain
  67. #
  68. # @return [Integer] Seconds between requests
  69. def rate_limit_seconds
  70. @rate_limit_seconds ||= load_rate_limit_config
  71. end
  72. # Loads rate limit from configuration
  73. #
  74. # @return [Integer] Seconds between requests
  75. def load_rate_limit_config
  76. config = YAML.load_file(Rails.root.join("config/rate_limits.yml"))
  77. # Try exact domain match first
  78. domain_limits = config["domains"] || {}
  79. return domain_limits[@domain] if domain_limits.key?(@domain)
  80. # Return default
  81. config["default"] || 5
  82. rescue => e
  83. Rails.logger.error("Failed to load rate limit config: #{e.message}")
  84. 5 # Safe default
  85. end
  86. end
  87. end

app/services/scraping/rendered_html_fetcher_service.rb

0.0% lines covered

100.0% branches covered

234 relevant lines. 0 lines covered and 234 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "selenium-webdriver"
  3. require "timeout"
  4. module Scraping
  5. # Service for fetching JS-rendered HTML using a headless browser (Selenium)
  6. #
  7. # Intended as a fallback when static HTTP fetch returns a shell page and the
  8. # job content is populated client-side via JavaScript.
  9. #
  10. # Notes:
  11. # - This service is expensive; it should be used selectively (heuristics + caching).
  12. # - It returns full page HTML and also provides cleaned_html using Nokogiri cleaner.
  13. # - Wrapped with Ruby Timeout to prevent indefinite hangs from Selenium.
  14. class RenderedHtmlFetcherService < ApplicationService
  15. DEFAULT_TIMEOUT_SECONDS = 30
  16. DEFAULT_WAIT_SECONDS = 10
  17. # Overall timeout for the entire operation (page load + wait + processing)
  18. # This is a hard limit to prevent indefinite hangs
  19. HARD_TIMEOUT_SECONDS = 90
  20. MAX_HTML_BYTES = 5.megabytes
  21. MAX_IFRAMES_TO_CHECK = 5
  22. # A realistic UA (some job boards degrade bot UAs).
  23. REALISTIC_UA =
  24. "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " \
  25. "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
  26. # Best-effort selectors for “job content is present”.
  27. JOB_CONTENT_SELECTORS = [
  28. "[data-testid*='job']",
  29. "[data-testid*='description']",
  30. "[data-testid*='posting']",
  31. "[class*='job-description']",
  32. "[class*='jobDescription']",
  33. "[class*='job-details']",
  34. "[class*='jobDetails']",
  35. "[id*='job-description']",
  36. "[id*='jobDescription']",
  37. "main"
  38. ].freeze
  39. attr_reader :job_listing, :scraping_attempt, :url
  40. # @param job_listing [JobListing]
  41. # @param scraping_attempt [ScrapingAttempt, nil]
  42. # @param timeout [Integer] Overall page-load timeout
  43. # @param wait [Integer] Extra wait for content to settle
  44. def initialize(job_listing, scraping_attempt: nil, timeout: DEFAULT_TIMEOUT_SECONDS, wait: DEFAULT_WAIT_SECONDS)
  45. @job_listing = job_listing
  46. @scraping_attempt = scraping_attempt
  47. @url = job_listing.url
  48. @timeout = timeout
  49. @wait = wait
  50. end
  51. # Fetches rendered HTML using Selenium headless Chrome
  52. #
  53. # Wrapped with a hard timeout to prevent indefinite hangs from Selenium/network issues.
  54. #
  55. # @return [Hash] Result with :success, :html_content, :cleaned_html, :http_status, :error, :cached_data
  56. def call
  57. return error_result("URL is required") if url.blank?
  58. return error_result("JS rendering disabled") unless Setting.js_rendering_enabled?
  59. # Wrap entire operation in a timeout to prevent indefinite hangs
  60. Timeout.timeout(HARD_TIMEOUT_SECONDS, RenderedFetchTimeoutError) do
  61. perform_fetch
  62. end
  63. rescue RenderedFetchTimeoutError => e
  64. Rails.logger.error("Rendered fetch hard timeout after #{HARD_TIMEOUT_SECONDS}s for #{url}")
  65. notify_error(
  66. e,
  67. context: "rendered_html_fetch_timeout",
  68. severity: "warning",
  69. url: url,
  70. job_listing_id: job_listing.id,
  71. scraping_attempt_id: scraping_attempt&.id,
  72. timeout_seconds: HARD_TIMEOUT_SECONDS
  73. )
  74. error_result("Rendered fetch timed out after #{HARD_TIMEOUT_SECONDS} seconds")
  75. rescue Selenium::WebDriver::Error::WebDriverError => e
  76. notify_error(
  77. e,
  78. context: "rendered_html_fetch",
  79. severity: "error",
  80. url: url,
  81. job_listing_id: job_listing.id,
  82. scraping_attempt_id: scraping_attempt&.id
  83. )
  84. error_result("Rendered fetch failed: #{e.message}")
  85. rescue StandardError => e
  86. notify_error(
  87. e,
  88. context: "rendered_html_fetch",
  89. severity: "error",
  90. url: url,
  91. job_listing_id: job_listing.id,
  92. scraping_attempt_id: scraping_attempt&.id
  93. )
  94. error_result("Rendered fetch failed: #{e.message}")
  95. end
  96. # Custom error for hard timeout
  97. class RenderedFetchTimeoutError < StandardError; end
  98. private
  99. # Performs the actual fetch operation (separated for timeout wrapping)
  100. def perform_fetch
  101. driver = nil
  102. begin
  103. driver = build_driver
  104. started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  105. driver.navigate.to(url)
  106. # Wait for document readiness
  107. wait_until(driver, @timeout) { driver.execute_script("return document.readyState") == "complete" }
  108. selector_probe = wait_for_job_content(driver)
  109. # Best-effort settle time for SPAs (bounded)
  110. sleep(@wait) if @wait.positive?
  111. html = driver.page_source.to_s
  112. if html.bytesize > MAX_HTML_BYTES
  113. return error_result("Rendered HTML too large (#{html.bytesize} bytes)")
  114. end
  115. # Use board-specific cleaner if available
  116. cleaner = Scraping::HtmlCleaners::CleanerFactory.cleaner_for_url(url)
  117. cleaned_html = cleaner.clean(html)
  118. iframe_result = selector_probe[:iframe_best_candidate]
  119. if iframe_result.present?
  120. # Prefer iframe HTML if it yields more extracted text
  121. iframe_cleaned = cleaner.clean(iframe_result[:iframe_html].to_s)
  122. if extracted_text_length(iframe_cleaned) > extracted_text_length(cleaned_html)
  123. html = iframe_result[:iframe_html].to_s
  124. cleaned_html = iframe_cleaned
  125. selector_probe[:iframe_used] = true
  126. end
  127. end
  128. cleaned_text_length = extracted_text_length(cleaned_html)
  129. duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
  130. cached_data = ScrapedJobListingData.create_with_html(
  131. url: url,
  132. html_content: html,
  133. job_listing: job_listing,
  134. scraping_attempt: scraping_attempt,
  135. http_status: nil,
  136. metadata: {
  137. fetched_via: "selenium",
  138. rendered: true,
  139. duration_ms: duration_ms,
  140. selector_found: selector_probe[:selector_found],
  141. found_selectors: selector_probe[:found_selectors],
  142. selector_wait_ms: selector_probe[:selector_wait_ms],
  143. iframe_used: selector_probe[:iframe_used],
  144. cleaned_text_length: cleaned_text_length
  145. }
  146. )
  147. {
  148. success: true,
  149. html_content: html,
  150. cleaned_html: cleaned_html,
  151. cached_data: cached_data,
  152. from_cache: false,
  153. http_status: nil,
  154. duration_ms: duration_ms,
  155. selector_found: selector_probe[:selector_found],
  156. found_selectors: selector_probe[:found_selectors],
  157. selector_wait_ms: selector_probe[:selector_wait_ms],
  158. iframe_used: selector_probe[:iframe_used],
  159. cleaned_text_length: cleaned_text_length
  160. }
  161. ensure
  162. driver&.quit
  163. end
  164. end
  165. def build_driver
  166. options = Selenium::WebDriver::Chrome::Options.new
  167. options.add_argument("--headless=new")
  168. options.add_argument("--no-sandbox")
  169. options.add_argument("--disable-dev-shm-usage")
  170. options.add_argument("--disable-gpu")
  171. options.add_argument("--window-size=1280,1024")
  172. options.add_argument("--lang=en-US")
  173. # Prefer a realistic UA to avoid degraded “bot” experiences.
  174. options.add_argument("--user-agent=#{REALISTIC_UA}")
  175. # Support remote Selenium Grid via environment variables (for scaling)
  176. if selenium_remote_url.present?
  177. driver = Selenium::WebDriver.for(:remote, url: selenium_remote_url, options: options)
  178. else
  179. # Local ChromeDriver (will auto-download via webdriver gem if needed)
  180. driver = Selenium::WebDriver.for(:chrome, options: options)
  181. end
  182. driver.manage.timeouts.page_load = @timeout
  183. driver
  184. end
  185. # Returns remote Selenium Grid URL if configured, nil otherwise
  186. #
  187. # @return [String, nil]
  188. def selenium_remote_url
  189. return nil unless ENV["SELENIUM_REMOTE_URL"].present?
  190. ENV["SELENIUM_REMOTE_URL"]
  191. end
  192. def wait_until(driver, seconds)
  193. wait = Selenium::WebDriver::Wait.new(timeout: seconds)
  194. wait.until { yield }
  195. rescue Selenium::WebDriver::Error::TimeoutError
  196. # Continue best-effort if readiness never reports complete
  197. nil
  198. end
  199. # Waits (best-effort) until job content appears in the DOM.
  200. #
  201. # @param driver [Selenium::WebDriver]
  202. # @return [Hash]
  203. def wait_for_job_content(driver)
  204. started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  205. found_selectors = []
  206. selector_found = false
  207. iframe_best_candidate = nil
  208. iframe_used = false
  209. selector_found, found_selectors = wait_for_any_selector(driver, JOB_CONTENT_SELECTORS, timeout: [ @timeout, 15 ].min)
  210. # If not found, check a small number of iframes for content (best-effort).
  211. if !selector_found
  212. iframes = driver.find_elements(css: "iframe").first(MAX_IFRAMES_TO_CHECK)
  213. iframes.each do |iframe|
  214. begin
  215. driver.switch_to.frame(iframe)
  216. iframe_found, iframe_selectors = wait_for_any_selector(driver, JOB_CONTENT_SELECTORS, timeout: 6)
  217. if iframe_found
  218. iframe_html = driver.page_source.to_s
  219. iframe_best_candidate = { iframe_html: iframe_html, found_selectors: iframe_selectors }
  220. break
  221. end
  222. rescue Selenium::WebDriver::Error::WebDriverError
  223. nil
  224. ensure
  225. begin
  226. driver.switch_to.default_content
  227. rescue Selenium::WebDriver::Error::WebDriverError
  228. nil
  229. end
  230. end
  231. end
  232. end
  233. selector_wait_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
  234. {
  235. selector_found: selector_found,
  236. found_selectors: found_selectors.first(10),
  237. selector_wait_ms: selector_wait_ms,
  238. iframe_used: iframe_used,
  239. iframe_best_candidate: iframe_best_candidate
  240. }
  241. rescue => e
  242. Rails.logger.debug("RenderedHtmlFetcherService wait_for_job_content error: #{e.message}")
  243. { selector_found: false, found_selectors: [], selector_wait_ms: nil, iframe_used: false, iframe_best_candidate: nil }
  244. end
  245. def wait_for_any_selector(driver, selectors, timeout:)
  246. found = []
  247. wait_until(driver, timeout) do
  248. found = selectors.select { |sel| dom_has_selector?(driver, sel) }
  249. found.any?
  250. end
  251. [ found.any?, found ]
  252. rescue
  253. [ false, [] ]
  254. end
  255. def dom_has_selector?(driver, selector)
  256. driver.execute_script("return !!document.querySelector(arguments[0])", selector) == true
  257. rescue Selenium::WebDriver::Error::JavascriptError
  258. false
  259. end
  260. # Rough “how much text do we have?” metric for deciding if rendered fetch worked.
  261. #
  262. # @param html [String]
  263. # @return [Integer]
  264. def extracted_text_length(html)
  265. Nokogiri::HTML(html.to_s).text.to_s.strip.length
  266. rescue
  267. 0
  268. end
  269. def error_result(message)
  270. {
  271. success: false,
  272. error: message,
  273. html_content: nil,
  274. cleaned_html: nil,
  275. cached_data: nil,
  276. from_cache: false
  277. }
  278. end
  279. end
  280. end

app/services/scraping/retry_service.rb

0.0% lines covered

100.0% branches covered

164 relevant lines. 0 lines covered and 164 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Service for retrying failed scraping attempts
  4. #
  5. # Provides idempotent retry logic for individual steps, leveraging
  6. # cached HTML when available to avoid re-fetching.
  7. #
  8. # @example
  9. # retry_service = Scraping::RetryService.new(scraping_attempt)
  10. # result = retry_service.retry_html_fetch
  11. # result = retry_service.retry_extraction
  12. # result = retry_service.retry_full
  13. class RetryService
  14. attr_reader :scraping_attempt, :job_listing
  15. # Initialize the retry service
  16. #
  17. # @param [ScrapingAttempt] scraping_attempt The failed scraping attempt
  18. def initialize(scraping_attempt)
  19. @scraping_attempt = scraping_attempt
  20. @job_listing = scraping_attempt.job_listing
  21. end
  22. # Retries HTML fetching step
  23. #
  24. # @return [Hash] Result hash with success status
  25. def retry_html_fetch
  26. return error_result("Attempt is not in a retryable state") unless can_retry_html_fetch?
  27. @scraping_attempt.retry_attempt! if @scraping_attempt.failed?
  28. @scraping_attempt.start_fetch!
  29. fetcher = HtmlFetcherService.new(@job_listing, scraping_attempt: @scraping_attempt)
  30. result = fetcher.call
  31. if result[:success]
  32. # Link cached data to this attempt
  33. result[:cached_data]&.update(scraping_attempt: @scraping_attempt) if result[:cached_data]
  34. success_result("HTML fetch succeeded", result)
  35. else
  36. @scraping_attempt.update(failed_step: "html_fetch", error_message: result[:error])
  37. @scraping_attempt.mark_failed!
  38. error_result(result[:error] || "HTML fetch failed")
  39. end
  40. rescue => e
  41. @scraping_attempt.update(failed_step: "html_fetch", error_message: e.message)
  42. @scraping_attempt.mark_failed!
  43. error_result(e.message)
  44. end
  45. # Retries extraction step (AI or API) using cached HTML
  46. #
  47. # @return [Hash] Result hash with success status
  48. def retry_extraction
  49. return error_result("Attempt is not in a retryable state") unless can_retry_extraction?
  50. # Get cached HTML if available
  51. cached_data = @scraping_attempt.cached_html_data
  52. unless cached_data
  53. return error_result("No cached HTML available for retry")
  54. end
  55. @scraping_attempt.retry_attempt! if @scraping_attempt.failed?
  56. @scraping_attempt.start_extract!
  57. # Try API extraction first if applicable
  58. detector = Scraping::JobBoardDetectorService.new(@job_listing.url)
  59. if Setting.api_population_enabled? && detector.api_supported? && detector.company_slug.present?
  60. api_result = try_api_extraction(detector.detect, detector.company_slug, detector.job_id)
  61. if api_result && api_result[:confidence] && api_result[:confidence] >= 0.7
  62. update_job_listing(api_result)
  63. complete_attempt(api_result)
  64. return success_result("API extraction succeeded", api_result)
  65. end
  66. end
  67. # Try AI extraction with cached HTML
  68. ai_result = try_ai_extraction_with_cache(cached_data)
  69. if ai_result && ai_result[:confidence] && ai_result[:confidence] >= 0.7
  70. update_job_listing(ai_result)
  71. complete_attempt(ai_result)
  72. success_result("AI extraction succeeded", ai_result)
  73. else
  74. @scraping_attempt.update(failed_step: "ai_extraction", error_message: "Low confidence: #{ai_result[:confidence] || 0.0}")
  75. @scraping_attempt.mark_failed!
  76. error_result("Extraction failed: Low confidence")
  77. end
  78. rescue => e
  79. @scraping_attempt.update(failed_step: "ai_extraction", error_message: e.message)
  80. @scraping_attempt.mark_failed!
  81. error_result(e.message)
  82. end
  83. # Retries the entire process from scratch
  84. #
  85. # @return [Hash] Result hash with success status
  86. def retry_full
  87. orchestrator = OrchestratorService.new(@job_listing)
  88. success = orchestrator.call
  89. if success
  90. success_result("Full retry succeeded")
  91. else
  92. error_result("Full retry failed")
  93. end
  94. end
  95. private
  96. # Checks if HTML fetch can be retried
  97. #
  98. # @return [Boolean] True if can retry
  99. def can_retry_html_fetch?
  100. @scraping_attempt.failed? || @scraping_attempt.retrying?
  101. end
  102. # Checks if extraction can be retried
  103. #
  104. # @return [Boolean] True if can retry
  105. def can_retry_extraction?
  106. (@scraping_attempt.failed? || @scraping_attempt.retrying?) &&
  107. (@scraping_attempt.ai_extraction_failed? || @scraping_attempt.api_extraction_failed?)
  108. end
  109. # Tries API extraction
  110. #
  111. # @param [Symbol] board_type The board type
  112. # @param [String] company_slug Company identifier
  113. # @param [String] job_id Job identifier
  114. # @return [Hash, nil] Extracted data or nil
  115. def try_api_extraction(board_type, company_slug, job_id)
  116. fetcher = get_api_fetcher(board_type)
  117. return nil unless fetcher
  118. fetcher.fetch(
  119. url: @job_listing.url,
  120. company_slug: company_slug,
  121. job_id: job_id
  122. )
  123. rescue => e
  124. Rails.logger.error("API extraction retry failed: #{e.message}")
  125. nil
  126. end
  127. # Gets the appropriate API fetcher for a board type
  128. #
  129. # @param [Symbol] board_type The board type
  130. # @return [ApiFetchers::BaseFetcher, nil] Fetcher instance or nil
  131. def get_api_fetcher(board_type)
  132. case board_type
  133. when :greenhouse
  134. ApiFetchers::GreenhouseFetcher.new
  135. when :lever
  136. ApiFetchers::LeverFetcher.new
  137. else
  138. nil
  139. end
  140. end
  141. # Tries AI extraction with cached HTML
  142. #
  143. # @param [ScrapedJobListingData] cached_data The cached HTML data
  144. # @return [Hash] Extracted data
  145. def try_ai_extraction_with_cache(cached_data)
  146. extractor = AiJobExtractorService.new(@job_listing, scraping_attempt: @scraping_attempt)
  147. extractor.extract(
  148. html_content: cached_data.html_content,
  149. cleaned_html: cached_data.cleaned_html
  150. )
  151. rescue => e
  152. Rails.logger.error("AI extraction retry failed: #{e.message}")
  153. { error: e.message, confidence: 0.0 }
  154. end
  155. # Updates the job listing with extracted data
  156. #
  157. # @param [Hash] result The extracted data
  158. def update_job_listing(result)
  159. @job_listing.update(
  160. title: result[:title] || @job_listing.title,
  161. description: result[:description] || @job_listing.description,
  162. requirements: result[:requirements] || @job_listing.requirements,
  163. responsibilities: result[:responsibilities] || @job_listing.responsibilities,
  164. salary_min: result[:salary_min] || @job_listing.salary_min,
  165. salary_max: result[:salary_max] || @job_listing.salary_max,
  166. salary_currency: result[:salary_currency] || @job_listing.salary_currency,
  167. equity_info: result[:equity_info] || @job_listing.equity_info,
  168. benefits: result[:benefits] || @job_listing.benefits,
  169. perks: result[:perks] || @job_listing.perks,
  170. location: result[:location] || @job_listing.location,
  171. remote_type: result[:remote_type] || @job_listing.remote_type,
  172. custom_sections: result[:custom_sections] || @job_listing.custom_sections,
  173. scraped_data: build_scraped_metadata(result)
  174. )
  175. end
  176. # Builds scraped metadata for storage
  177. #
  178. # @param [Hash] result The extraction result
  179. # @return [Hash] Metadata hash
  180. def build_scraped_metadata(result)
  181. {
  182. status: "completed",
  183. extraction_method: result[:extraction_method] || "ai",
  184. provider: result[:provider],
  185. model: result[:model],
  186. confidence_score: result[:confidence],
  187. tokens_used: result[:tokens_used],
  188. extracted_at: Time.current.iso8601,
  189. retried: true
  190. }
  191. end
  192. # Completes the attempt successfully
  193. #
  194. # @param [Hash] result The extraction result
  195. def complete_attempt(result)
  196. @scraping_attempt.update(
  197. extraction_method: result[:extraction_method] || "ai",
  198. provider: result[:provider],
  199. confidence_score: result[:confidence],
  200. duration_seconds: Time.current - @scraping_attempt.created_at,
  201. response_metadata: {
  202. model: result[:model],
  203. tokens_used: result[:tokens_used]
  204. }
  205. )
  206. @scraping_attempt.mark_completed!
  207. end
  208. # Returns a success result hash
  209. #
  210. # @param [String] message Success message
  211. # @param [Hash] data Additional data
  212. # @return [Hash] Success result
  213. def success_result(message, data = {})
  214. {
  215. success: true,
  216. message: message
  217. }.merge(data)
  218. end
  219. # Returns an error result hash
  220. #
  221. # @param [String] error_message The error message
  222. # @return [Hash] Error result
  223. def error_result(error_message)
  224. {
  225. success: false,
  226. error: error_message
  227. }
  228. end
  229. end
  230. end

app/services/scraping/robots_txt_checker_service.rb

0.0% lines covered

100.0% branches covered

57 relevant lines. 0 lines covered and 57 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Service for checking robots.txt compliance
  4. #
  5. # Fetches and caches robots.txt files, then checks if a URL is allowed
  6. # to be scraped according to the robots.txt rules.
  7. #
  8. # @example
  9. # checker = Scraping::RobotsTxtCheckerService.new("https://example.com/jobs/123")
  10. # if checker.allowed?
  11. # # Proceed with scraping
  12. # end
  13. class RobotsTxtCheckerService
  14. USER_AGENT = "GleaniaBot/1.0"
  15. # Initialize the robots.txt checker for a URL
  16. #
  17. # @param [String] url The URL to check
  18. def initialize(url)
  19. @url = url
  20. @uri = URI.parse(url)
  21. @domain = @uri.host
  22. end
  23. # Checks if scraping this URL is allowed per robots.txt
  24. #
  25. # @return [Boolean] True if allowed or if robots.txt doesn't exist
  26. def allowed?
  27. return true unless @domain # Can't check without domain
  28. begin
  29. # The robots gem automatically fetches robots.txt for the domain
  30. parser = Robots.new(USER_AGENT)
  31. parser.allowed?(@url)
  32. rescue => e
  33. Rails.logger.warn("Failed to check robots.txt for #{@domain}: #{e.message}")
  34. true # On error, allow by default to not block functionality
  35. end
  36. end
  37. # Returns the crawl delay specified in robots.txt
  38. #
  39. # @return [Integer, nil] Delay in seconds or nil if not specified
  40. def crawl_delay
  41. robots_txt_content = fetch_robots_txt
  42. return nil if robots_txt_content.nil?
  43. # Parse crawl-delay directive
  44. match = robots_txt_content.match(/Crawl-delay:\s*(\d+)/i)
  45. match ? match[1].to_i : nil
  46. rescue => e
  47. Rails.logger.warn("Failed to get crawl delay for #{@domain}: #{e.message}")
  48. nil
  49. end
  50. private
  51. # Fetches robots.txt content for the domain
  52. #
  53. # @return [String, nil] robots.txt content or nil if not found
  54. def fetch_robots_txt
  55. cache_key = "robots_txt:#{@domain}"
  56. Rails.cache.fetch(cache_key, expires_in: 24.hours) do
  57. fetch_robots_txt_from_server
  58. end
  59. end
  60. # Fetches robots.txt from the server
  61. #
  62. # @return [String, nil] robots.txt content or nil
  63. def fetch_robots_txt_from_server
  64. robots_url = "#{@uri.scheme}://#{@domain}/robots.txt"
  65. response = HTTParty.get(
  66. robots_url,
  67. headers: {
  68. "User-Agent" => USER_AGENT
  69. },
  70. timeout: 10,
  71. follow_redirects: true
  72. )
  73. if response.success?
  74. Rails.logger.info("Fetched robots.txt for #{@domain}")
  75. response.body
  76. else
  77. Rails.logger.info("No robots.txt found for #{@domain} (#{response.code})")
  78. nil
  79. end
  80. rescue => e
  81. Rails.logger.warn("Failed to fetch robots.txt for #{@domain}: #{e.message}")
  82. nil
  83. end
  84. end
  85. end

app/services/scraping/salary_range_validator.rb

0.0% lines covered

100.0% branches covered

67 relevant lines. 0 lines covered and 67 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Scraping
  3. # Validates and normalizes salary ranges extracted from job listings.
  4. #
  5. # This is intentionally conservative: it's better to show no salary range than
  6. # to show one that is likely a false-positive (e.g., "89 - 7 USD" coming from
  7. # unrelated numbers in the page).
  8. #
  9. # @example
  10. # res = Scraping::SalaryRangeValidator.normalize(min: 120_000, max: 150_000, currency: "USD", context_text: "per year")
  11. # res[:valid] # => true
  12. #
  13. class SalaryRangeValidator
  14. MIN_ANNUAL_SALARY = 10_000
  15. MAX_ANNUAL_SALARY = 2_000_000
  16. CURRENCY_RE = /\A[A-Z]{3}\z/
  17. # Normalizes and validates a salary range.
  18. #
  19. # @param min [Numeric, String, nil]
  20. # @param max [Numeric, String, nil]
  21. # @param currency [String, nil]
  22. # @param context_text [String, nil] Nearby text to infer units (year/month/hour)
  23. # @return [Hash] { valid:, min:, max:, currency:, reason: }
  24. def self.normalize(min:, max:, currency:, context_text: nil)
  25. min_n = coerce_number(min)
  26. max_n = coerce_number(max)
  27. cur = currency.to_s.strip.upcase.presence
  28. return invalid("missing_salary") if min_n.nil? && max_n.nil?
  29. return invalid("missing_currency") if cur.blank? || cur !~ CURRENCY_RE
  30. return invalid("inverted_range") if min_n && max_n && max_n < min_n
  31. unit = infer_unit(context_text.to_s)
  32. return invalid("non_annual_unit") if unit && unit != :year
  33. return invalid("min_out_of_bounds") if min_n && !annual_amount_plausible?(min_n)
  34. return invalid("max_out_of_bounds") if max_n && !annual_amount_plausible?(max_n)
  35. {
  36. valid: true,
  37. min: min_n,
  38. max: max_n,
  39. currency: cur,
  40. reason: nil
  41. }
  42. end
  43. def self.invalid(reason)
  44. { valid: false, min: nil, max: nil, currency: nil, reason: reason }
  45. end
  46. def self.annual_amount_plausible?(amount)
  47. amount >= MIN_ANNUAL_SALARY && amount <= MAX_ANNUAL_SALARY
  48. end
  49. def self.infer_unit(text)
  50. t = text.to_s.downcase
  51. return :hour if t.match?(/\b(per\s*hour|hourly|\/\s*hr|\/\s*h)\b/)
  52. return :month if t.match?(/\b(per\s*month|monthly|\/\s*mo|\/\s*month)\b/)
  53. return :year if t.match?(/\b(per\s*year|annual|yearly|\/\s*yr|\/\s*year)\b/)
  54. nil
  55. end
  56. def self.coerce_number(value)
  57. return nil if value.nil?
  58. return value.to_f if value.is_a?(Numeric)
  59. str = value.to_s.strip
  60. return nil if str.blank?
  61. # Remove currency symbols and whitespace, keep digits, dots, commas, and "k".
  62. cleaned = str.gsub(/[^\d.,kK]/, "")
  63. return nil if cleaned.blank?
  64. multiplier = 1.0
  65. if cleaned.match?(/[kK]\z/)
  66. multiplier = 1000.0
  67. cleaned = cleaned.gsub(/[kK]\z/, "")
  68. end
  69. num = parse_decimalish(cleaned)
  70. num ? (num * multiplier) : nil
  71. end
  72. def self.parse_decimalish(str)
  73. s = str.to_s
  74. return nil if s.blank?
  75. # If it looks like a decimal-comma (e.g., "89,7"), treat comma as decimal separator.
  76. if s.include?(",") && !s.include?(".") && s.match?(/\A\d+,\d{1,2}\z/)
  77. s = s.tr(",", ".")
  78. else
  79. # Otherwise treat commas as thousands separators.
  80. s = s.delete(",")
  81. end
  82. Float(s)
  83. rescue ArgumentError, TypeError
  84. nil
  85. end
  86. private_class_method :annual_amount_plausible?, :infer_unit, :coerce_number, :parse_decimalish, :invalid
  87. end
  88. end

app/services/signals/action_applier.rb

0.0% lines covered

100.0% branches covered

112 relevant lines. 0 lines covered and 112 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Applies planned actions in deterministic order.
  4. class ActionApplier < ApplicationService
  5. ACTION_ORDER = [
  6. :mark_latest_round_failed,
  7. :sync_application_from_round_result,
  8. :set_application_status,
  9. :set_pipeline_stage,
  10. :sync_pipeline_from_round_stage
  11. ].freeze
  12. def initialize(context)
  13. @context = context
  14. @application = context.application
  15. end
  16. def apply!(actions)
  17. return [] if actions.blank? || @application.blank?
  18. applied = []
  19. ordered = actions.sort_by { |action| ACTION_ORDER.index(action[:type]) || ACTION_ORDER.length }
  20. @application.reload
  21. ordered.each do |action|
  22. case action[:type]
  23. when :mark_latest_round_failed
  24. applied << apply_mark_latest_round_failed
  25. when :sync_application_from_round_result
  26. applied << apply_application_from_round_result
  27. when :set_application_status
  28. applied << apply_application_status(action[:status])
  29. when :set_pipeline_stage
  30. applied << apply_pipeline_stage(action[:stage])
  31. when :sync_pipeline_from_round_stage
  32. applied << apply_pipeline_from_round_stage
  33. end
  34. end
  35. applied.compact
  36. rescue StandardError => e
  37. notify_error(
  38. e,
  39. context: "signal_action_applier",
  40. user: @context.synced_email&.user,
  41. synced_email_id: @context.synced_email&.id,
  42. application_id: @application&.id
  43. )
  44. log_error("Failed to apply actions for email #{@context.synced_email&.id}: #{e.message}")
  45. []
  46. end
  47. private
  48. def apply_mark_latest_round_failed
  49. round = pending_rounds.first || latest_round
  50. return nil unless round&.result == "pending"
  51. round.update!(result: :failed, completed_at: Time.current)
  52. { type: :mark_latest_round_failed, round_id: round.id }
  53. end
  54. def apply_application_from_round_result
  55. round = latest_round || pending_rounds.first
  56. return nil unless round
  57. case round.result
  58. when "failed"
  59. apply_application_status(:rejected)
  60. apply_pipeline_stage(:closed)
  61. when "passed", "waitlisted"
  62. apply_pipeline_stage(:interviewing)
  63. end
  64. end
  65. def apply_application_status(status)
  66. case status&.to_sym
  67. when :rejected
  68. return nil unless @application.may_reject?
  69. @application.reject!
  70. when :accepted
  71. return nil unless @application.may_accept?
  72. @application.accept!
  73. when :archived
  74. return nil unless @application.may_archive?
  75. @application.archive!
  76. when :on_hold
  77. return nil unless @application.respond_to?(:may_hold?) && @application.may_hold?
  78. @application.hold!
  79. when :withdrawn
  80. return nil unless @application.respond_to?(:may_withdraw?) && @application.may_withdraw?
  81. @application.withdraw!
  82. when :active
  83. return nil unless @application.may_reactivate?
  84. @application.reactivate!
  85. end
  86. { type: :set_application_status, status: @application.status }
  87. end
  88. def apply_pipeline_stage(stage)
  89. event_method = case stage&.to_sym
  90. when :screening then :move_to_screening
  91. when :interviewing then :move_to_interviewing
  92. when :offer then :move_to_offer
  93. when :closed then :move_to_closed
  94. when :applied then :move_to_applied
  95. end
  96. return nil unless event_method
  97. return nil unless @application.aasm(:pipeline_stage).may_fire_event?(event_method)
  98. @application.send("#{event_method}!")
  99. { type: :set_pipeline_stage, stage: @application.pipeline_stage }
  100. end
  101. def apply_pipeline_from_round_stage
  102. round = latest_round
  103. return nil unless round
  104. target_stage = round.stage == "screening" ? :screening : :interviewing
  105. apply_pipeline_stage(target_stage)
  106. end
  107. def latest_round
  108. @application.interview_rounds.ordered.last
  109. end
  110. def pending_rounds
  111. @application.interview_rounds.where(result: :pending).order(scheduled_at: :desc)
  112. end
  113. end
  114. end

app/services/signals/action_executor.rb

0.0% lines covered

100.0% branches covered

47 relevant lines. 0 lines covered and 47 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Executes backend signal actions derived from email content
  4. #
  5. # Dispatches to specific action handlers based on action type.
  6. # Only handles actions that require backend processing (not simple URL opens).
  7. #
  8. # @example
  9. # executor = Signals::ActionExecutor.new(synced_email, user, "start_application")
  10. # result = executor.execute
  11. # if result[:success]
  12. # # Action completed successfully
  13. # end
  14. #
  15. class ActionExecutor < ApplicationService
  16. attr_reader :synced_email, :user, :action_type, :params
  17. # Valid backend action types that require user decision
  18. # Note: URL-based actions are handled directly in the UI via action_links
  19. # Note: Recruiter/company saving happens automatically during extraction
  20. # Note: match_application is handled via dropdown in detail panel
  21. VALID_ACTIONS = %w[
  22. start_application
  23. ].freeze
  24. # Initialize the executor
  25. #
  26. # @param synced_email [SyncedEmail] The email with extracted signals
  27. # @param user [User] The user executing the action
  28. # @param action_type [String] The action to execute
  29. # @param params [Hash] Additional parameters for the action
  30. def initialize(synced_email, user, action_type, params = {})
  31. @synced_email = synced_email
  32. @user = user
  33. @action_type = action_type.to_s
  34. # Handle both ActionController::Parameters and regular Hash
  35. @params = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h.with_indifferent_access : params.to_h.with_indifferent_access
  36. end
  37. # Executes the action
  38. #
  39. # @return [Hash] Result with success status, message, and optional redirect/data
  40. def execute
  41. return invalid_action_result unless valid_action?
  42. action_class = action_handler_class
  43. return unsupported_action_result unless action_class
  44. handler = action_class.new(synced_email, user, params)
  45. handler.execute
  46. rescue StandardError => e
  47. Rails.logger.error("Signal action execution failed: #{e.class} - #{e.message}")
  48. notify_error(
  49. e,
  50. context: "signal_action",
  51. user: user,
  52. action_type: action_type,
  53. synced_email_id: synced_email&.id
  54. )
  55. { success: false, error: e.message }
  56. end
  57. # Checks if the action type is valid
  58. #
  59. # @return [Boolean]
  60. def valid_action?
  61. VALID_ACTIONS.include?(action_type)
  62. end
  63. private
  64. # Returns the handler class for the action type
  65. #
  66. # @return [Class, nil]
  67. def action_handler_class
  68. case action_type
  69. when "start_application"
  70. Actions::StartApplicationAction
  71. end
  72. end
  73. # Result for invalid action type
  74. #
  75. # @return [Hash]
  76. def invalid_action_result
  77. { success: false, error: "Invalid action type: #{action_type}" }
  78. end
  79. # Result for unsupported action type
  80. #
  81. # @return [Hash]
  82. def unsupported_action_result
  83. { success: false, error: "Action not supported: #{action_type}" }
  84. end
  85. end
  86. end

app/services/signals/actions/base_action.rb

0.0% lines covered

100.0% branches covered

55 relevant lines. 0 lines covered and 55 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Actions
  4. # Base class for signal actions
  5. #
  6. # Provides common functionality for all action handlers.
  7. # Subclasses must implement the #execute method.
  8. #
  9. class BaseAction
  10. attr_reader :synced_email, :user, :params
  11. # Initialize the action
  12. #
  13. # @param synced_email [SyncedEmail] The email with extracted signals
  14. # @param user [User] The user executing the action
  15. # @param params [Hash] Additional parameters
  16. def initialize(synced_email, user, params = {})
  17. @synced_email = synced_email
  18. @user = user
  19. @params = params.with_indifferent_access
  20. end
  21. # Executes the action
  22. #
  23. # @return [Hash] Result with success status and relevant data
  24. def execute
  25. raise NotImplementedError, "Subclasses must implement #execute"
  26. end
  27. protected
  28. # Returns the extracted company name
  29. #
  30. # @return [String, nil]
  31. def company_name
  32. synced_email.signal_company_name
  33. end
  34. # Returns the extracted company website
  35. #
  36. # @return [String, nil]
  37. def company_website
  38. synced_email.signal_company_website
  39. end
  40. # Returns the extracted careers URL
  41. #
  42. # @return [String, nil]
  43. def careers_url
  44. synced_email.signal_company_careers_url
  45. end
  46. # Returns the extracted job title
  47. #
  48. # @return [String, nil]
  49. def job_title
  50. synced_email.signal_job_title
  51. end
  52. # Returns the extracted job URL
  53. #
  54. # @return [String, nil]
  55. def job_url
  56. synced_email.signal_job_url
  57. end
  58. # Returns the first scheduling link from action_links
  59. #
  60. # @return [String, nil]
  61. def scheduling_link
  62. return nil unless synced_email.signal_action_links.is_a?(Array)
  63. # Find first link with priority 1 (scheduling links) or label containing "schedule"
  64. scheduling = synced_email.signal_action_links.find do |link|
  65. link["priority"] == 1 || link["action_label"]&.downcase&.include?("schedule")
  66. end
  67. scheduling&.dig("url")
  68. end
  69. # Returns the extracted recruiter name
  70. #
  71. # @return [String, nil]
  72. def recruiter_name
  73. synced_email.signal_recruiter_name
  74. end
  75. # Returns the extracted recruiter email
  76. #
  77. # @return [String, nil]
  78. def recruiter_email
  79. synced_email.signal_recruiter_email || synced_email.from_email
  80. end
  81. # Builds a success result
  82. #
  83. # @param message [String] Success message
  84. # @param data [Hash] Additional data
  85. # @return [Hash]
  86. def success_result(message, data = {})
  87. { success: true, message: message }.merge(data)
  88. end
  89. # Builds a failure result
  90. #
  91. # @param error [String] Error message
  92. # @return [Hash]
  93. def failure_result(error)
  94. { success: false, error: error }
  95. end
  96. # Builds a redirect result
  97. #
  98. # @param url [String] URL to redirect to
  99. # @param message [String] Optional message
  100. # @return [Hash]
  101. def redirect_result(url, message = nil)
  102. result = { success: true, redirect_url: url, external: true }
  103. result[:message] = message if message
  104. result
  105. end
  106. end
  107. end
  108. end

app/services/signals/actions/start_application_action.rb

0.0% lines covered

100.0% branches covered

152 relevant lines. 0 lines covered and 152 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Actions
  4. # Creates a new interview application from extracted signal data
  5. #
  6. # Uses the company name, job title, and other extracted information
  7. # to create a new InterviewApplication record and optionally a Company.
  8. # If a job URL is detected, also creates a JobListing and triggers scraping.
  9. #
  10. # @example
  11. # action = StartApplicationAction.new(synced_email, user, {})
  12. # result = action.execute
  13. # # => { success: true, application: InterviewApplication, company: Company, job_listing: JobListing }
  14. #
  15. class StartApplicationAction < BaseAction
  16. # Job URL detection patterns for action links
  17. JOB_LINK_LABELS = /view.*job|job.*posting|apply|see.*position|full.*description|job.*details/i
  18. JOB_URL_PATTERNS = /lever\.co|greenhouse\.io|workday|myworkday|jobs\.|careers\.|ashbyhq\.com|smartrecruiters|jobvite|icims|bamboohr/i
  19. # Executes the action to create a new application
  20. #
  21. # @return [Hash] Result with created application and company
  22. def execute
  23. return failure_result("No company name extracted") unless company_name.present?
  24. ActiveRecord::Base.transaction do
  25. # Find or create the company (global model)
  26. company = find_or_create_company
  27. # Find or create job role
  28. job_role = find_or_create_job_role
  29. # Create job listing if we have a URL
  30. job_listing = create_job_listing_if_url_present(company, job_role)
  31. # Create the application with optional job listing
  32. application = create_application(company, job_role, job_listing)
  33. # Link the email to the application
  34. synced_email.match_to_application!(application)
  35. # Trigger job listing scraping in background if we have a new listing
  36. if job_listing.present? && job_listing.extraction_status == "pending"
  37. ScrapeJobListingJob.perform_later(job_listing)
  38. end
  39. success_result(
  40. "Application started at #{company.name}",
  41. application: application,
  42. company: company,
  43. job_listing: job_listing,
  44. redirect_path: Rails.application.routes.url_helpers.interview_application_path(application)
  45. )
  46. end
  47. rescue ActiveRecord::RecordInvalid => e
  48. failure_result("Failed to create application: #{e.message}")
  49. end
  50. private
  51. # Finds or creates a company from extracted data
  52. # Note: Company is a global model, not user-scoped
  53. #
  54. # @return [Company]
  55. def find_or_create_company
  56. normalized_name = normalize_company_name(company_name)
  57. # Try to find existing company by name (case-insensitive)
  58. existing = Company.find_by("LOWER(name) = ?", normalized_name.downcase)
  59. if existing
  60. # Update website if we have one and it's missing
  61. if company_website.present? && existing.website.blank?
  62. existing.update(website: company_website)
  63. end
  64. return existing
  65. end
  66. # Create new company with extracted data
  67. Company.create!(
  68. name: normalized_name,
  69. website: company_website
  70. )
  71. end
  72. # Finds or creates a job role from extracted data
  73. #
  74. # @return [JobRole]
  75. def find_or_create_job_role
  76. role_title = job_title.presence || "Position via #{recruiter_name || 'Recruiter'}"
  77. # Try to find existing
  78. existing = JobRole.find_by("LOWER(title) = ?", role_title.downcase)
  79. return existing if existing
  80. # Create new job role
  81. JobRole.create!(title: role_title)
  82. end
  83. # Detects job URL from extracted signals
  84. # Checks signal_job_url first, then looks in action_links for job-related URLs
  85. #
  86. # @return [String, nil]
  87. def detected_job_url
  88. # Priority 1: Direct job URL from signal extraction
  89. return job_url if job_url.present?
  90. # Priority 2: Look in action_links for job-related URLs
  91. return nil unless synced_email.signal_action_links.is_a?(Array)
  92. job_link = synced_email.signal_action_links.find do |link|
  93. next unless link.is_a?(Hash)
  94. label = link["action_label"].to_s
  95. url = link["url"].to_s
  96. # Match labels that indicate job postings
  97. next true if label.match?(JOB_LINK_LABELS)
  98. # Match URLs that look like job posting platforms
  99. next true if url.match?(JOB_URL_PATTERNS)
  100. false
  101. end
  102. job_link&.dig("url")
  103. end
  104. # Creates a job listing if we have a URL
  105. # Finds existing listing by URL or creates a new one
  106. #
  107. # @param company [Company] The company
  108. # @param job_role [JobRole] The job role
  109. # @return [JobListing, nil]
  110. def create_job_listing_if_url_present(company, job_role)
  111. url = detected_job_url
  112. return nil unless url.present?
  113. # Normalize URL for comparison
  114. normalized_url = normalize_job_url(url)
  115. # Check if job listing already exists for this URL
  116. existing = JobListing.find_by(url: normalized_url)
  117. return existing if existing
  118. # Also check without query params for some URLs
  119. base_url = normalized_url.split("?").first
  120. existing_base = JobListing.find_by(url: base_url) if base_url != normalized_url
  121. return existing_base if existing_base
  122. # Create new job listing (extraction_status defaults to "pending" via scraped_data)
  123. JobListing.create!(
  124. url: normalized_url,
  125. company: company,
  126. job_role: job_role,
  127. title: job_title.presence || "#{job_role.title} at #{company.name}",
  128. status: :active
  129. )
  130. end
  131. # Normalizes a job URL for consistent storage
  132. #
  133. # @param url [String] The raw URL
  134. # @return [String]
  135. def normalize_job_url(url)
  136. uri = URI.parse(url.strip)
  137. # Remove tracking parameters but keep job-specific ones
  138. if uri.query.present?
  139. params = URI.decode_www_form(uri.query).reject do |key, _|
  140. # Remove common tracking params
  141. %w[utm_source utm_medium utm_campaign utm_content utm_term ref source].include?(key.downcase)
  142. end
  143. uri.query = params.any? ? URI.encode_www_form(params) : nil
  144. end
  145. uri.to_s
  146. rescue URI::InvalidURIError
  147. url.strip
  148. end
  149. # Creates the interview application
  150. #
  151. # @param company [Company] The company
  152. # @param job_role [JobRole] The job role
  153. # @param job_listing [JobListing, nil] Optional job listing
  154. # @return [InterviewApplication]
  155. def create_application(company, job_role, job_listing = nil)
  156. user.interview_applications.create!(
  157. company: company,
  158. job_role: job_role,
  159. job_listing: job_listing,
  160. applied_at: synced_email.email_date || Time.current,
  161. notes: build_application_notes
  162. )
  163. end
  164. # Normalizes company name
  165. #
  166. # @param name [String]
  167. # @return [String]
  168. def normalize_company_name(name)
  169. normalized = name.strip
  170. suffixes = [
  171. /\s+inc\.?$/i,
  172. /\s+llc\.?$/i,
  173. /\s+corp\.?$/i,
  174. /\s+ltd\.?$/i,
  175. /\s+co\.?$/i
  176. ]
  177. suffixes.each { |suffix| normalized = normalized.gsub(suffix, "") }
  178. normalized.strip.titleize
  179. end
  180. # Builds notes for the application
  181. # Uses emoji and clean formatting for readability
  182. #
  183. # @return [String]
  184. def build_application_notes
  185. lines = []
  186. lines << "📬 Created from email signal"
  187. lines << ""
  188. # Recruiter section
  189. if recruiter_name.present?
  190. lines << "👤 RECRUITER"
  191. lines << " #{recruiter_name}"
  192. lines << " #{synced_email.signal_recruiter_title}" if synced_email.signal_recruiter_title.present?
  193. lines << " #{recruiter_email}" if recruiter_email.present?
  194. lines << ""
  195. end
  196. # Job details section
  197. details = []
  198. details << "📍 #{synced_email.signal_job_location}" if synced_email.signal_job_location.present?
  199. details << "🏢 #{synced_email.signal_job_department}" if synced_email.signal_job_department.present?
  200. details << "💰 #{synced_email.signal_job_salary_hint}" if synced_email.signal_job_salary_hint.present?
  201. if details.any?
  202. lines << "📋 DETAILS"
  203. details.each { |d| lines << " #{d.sub(/^.{2}/, '')}" } # Remove emoji from sub-items
  204. lines << ""
  205. end
  206. # Scheduling link (friendly name, not raw URL)
  207. if scheduling_link.present?
  208. friendly_name = extract_scheduling_platform(scheduling_link)
  209. lines << "📅 NEXT STEP"
  210. lines << " Schedule via #{friendly_name}"
  211. end
  212. lines.join("\n").strip
  213. end
  214. # Extracts a friendly platform name from scheduling URL
  215. #
  216. # @param url [String]
  217. # @return [String]
  218. def extract_scheduling_platform(url)
  219. case url
  220. when /goodtime\.io/i then "GoodTime"
  221. when /calendly\.com/i then "Calendly"
  222. when /cal\.com/i then "Cal.com"
  223. when /doodle\.com/i then "Doodle"
  224. when /zoom\.us.*schedule/i then "Zoom"
  225. when /meet\.google/i then "Google Meet"
  226. else "scheduling link"
  227. end
  228. end
  229. end
  230. end
  231. end

app/services/signals/application_status_processor.rb

0.0% lines covered

100.0% branches covered

331 relevant lines. 0 lines covered and 331 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Service for processing application status change emails (rejection, offer)
  4. #
  5. # Processes emails classified as rejection or offer to update application status
  6. # and create company feedback records.
  7. #
  8. # @example
  9. # processor = Signals::ApplicationStatusProcessor.new(synced_email)
  10. # result = processor.process
  11. # if result[:success]
  12. # # Application status updated
  13. # end
  14. #
  15. class ApplicationStatusProcessor < ApplicationService
  16. attr_reader :synced_email, :application
  17. # Email types that this processor handles
  18. PROCESSABLE_TYPES = %w[rejection offer].freeze
  19. # Minimum confidence score to accept extraction results
  20. MIN_CONFIDENCE_SCORE = 0.6
  21. # Operation type for logging
  22. OPERATION_TYPE = :application_status_extraction
  23. # Initialize the processor
  24. #
  25. # @param synced_email [SyncedEmail] The email to process
  26. def initialize(synced_email)
  27. @synced_email = synced_email
  28. @application = synced_email.interview_application
  29. end
  30. # Processes the email to update application status
  31. #
  32. # @return [Hash] Result with success status
  33. def process
  34. Rails.logger.info("[ApplicationStatusProcessor] Processing email ##{synced_email.id}: #{synced_email.subject}")
  35. return skip_result("Email not matched to application") unless application
  36. return skip_result("Email type not processable") unless processable?
  37. return skip_result("No email content") unless content_available?
  38. # Extract status data using LLM
  39. extraction = extract_status_data
  40. unless extraction[:success]
  41. Rails.logger.warn("[ApplicationStatusProcessor] Extraction failed for email ##{synced_email.id}: #{extraction[:error]}")
  42. return { success: false, error: extraction[:error] }
  43. end
  44. data = extraction[:data]
  45. status_change = data[:status_change] || {}
  46. result = case status_change[:type]
  47. when "rejection"
  48. handle_rejection(data)
  49. when "offer"
  50. handle_offer(data)
  51. when "withdrawal", "ghosted", "on_hold"
  52. handle_other_status(data)
  53. else
  54. skip_result("No status change detected")
  55. end
  56. result[:llm_api_log_id] = extraction[:llm_api_log_id] if extraction[:llm_api_log_id]
  57. result
  58. rescue StandardError => e
  59. notify_error(
  60. e,
  61. context: "application_status_processor",
  62. user: synced_email&.user,
  63. synced_email_id: synced_email&.id,
  64. application_id: application&.id,
  65. email_type: synced_email&.email_type,
  66. company: application&.company&.name
  67. )
  68. Rails.logger.error("[ApplicationStatusProcessor] Error processing email ##{synced_email&.id}: #{e.message}")
  69. { success: false, error: e.message }
  70. end
  71. private
  72. # Checks if email type is processable
  73. #
  74. # @return [Boolean]
  75. def processable?
  76. PROCESSABLE_TYPES.include?(synced_email.email_type)
  77. end
  78. # Checks if email content is available
  79. #
  80. # @return [Boolean]
  81. def content_available?
  82. synced_email.body_preview.present? ||
  83. synced_email.body_html.present? ||
  84. synced_email.snippet.present?
  85. end
  86. # Returns skip result
  87. #
  88. # @param reason [String]
  89. # @return [Hash]
  90. def skip_result(reason)
  91. Rails.logger.info("[ApplicationStatusProcessor] Skipped email ##{synced_email&.id}: #{reason}")
  92. { success: false, skipped: true, reason: reason }
  93. end
  94. # Extracts status data using LLM with observability
  95. #
  96. # @return [Hash] Result with success and data
  97. def extract_status_data
  98. prompt = build_prompt
  99. prompt_template = Ai::StatusExtractionPrompt.active_prompt
  100. system_message = prompt_template&.system_prompt.presence || Ai::StatusExtractionPrompt.default_system_prompt
  101. runner = Ai::ProviderRunnerService.new(
  102. provider_chain: provider_chain,
  103. prompt: prompt,
  104. content_size: extract_body_content.bytesize,
  105. system_message: system_message,
  106. provider_for: method(:get_provider_instance),
  107. run_options: { max_tokens: 1500, temperature: 0.1 },
  108. logger_builder: lambda { |provider_name, provider|
  109. Ai::ApiLoggerService.new(
  110. operation_type: OPERATION_TYPE,
  111. loggable: synced_email,
  112. provider: provider_name,
  113. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  114. llm_prompt: prompt_template
  115. )
  116. },
  117. operation: OPERATION_TYPE,
  118. loggable: synced_email,
  119. user: synced_email&.user,
  120. error_context: {
  121. severity: "warning",
  122. synced_email_id: synced_email&.id,
  123. application_id: application&.id
  124. }
  125. )
  126. result = runner.run do |response|
  127. parsed = parse_response(response[:content])
  128. status_change = parsed[:status_change] || {}
  129. rejection = parsed[:rejection_details] || {}
  130. offer = parsed[:offer_details] || {}
  131. feedback = parsed[:feedback] || {}
  132. log_data = {
  133. confidence: parsed&.dig(:confidence_score),
  134. status_type: status_change[:type],
  135. is_final: status_change[:is_final],
  136. sentiment: parsed[:sentiment],
  137. has_feedback: feedback[:has_feedback],
  138. rejection_reason: rejection[:reason].present?,
  139. offer_role: offer[:role_title],
  140. extracted_fields: extract_field_names(parsed)
  141. }.compact
  142. confidence_score = parsed[:confidence_score]
  143. if confidence_score && confidence_score < MIN_CONFIDENCE_SCORE
  144. Rails.logger.warn("[ApplicationStatusProcessor] Low confidence (#{confidence_score}) from provider")
  145. end
  146. accept = confidence_score.nil? || confidence_score >= MIN_CONFIDENCE_SCORE
  147. [ parsed, log_data, accept ]
  148. end
  149. return { success: false, error: "Failed to extract status data from email" } unless result[:success]
  150. status_type = result[:parsed].dig(:status_change, :type)
  151. Rails.logger.info("[ApplicationStatusProcessor] Successfully extracted with #{result[:provider]} (confidence: #{result[:parsed]&.dig(:confidence_score)}, type: #{status_type})")
  152. {
  153. success: true,
  154. data: result[:parsed],
  155. provider: result[:provider],
  156. llm_api_log_id: result[:llm_api_log_id],
  157. latency_ms: result[:latency_ms]
  158. }
  159. end
  160. # Extracts field names that were populated
  161. #
  162. # @param parsed [Hash]
  163. # @return [Array<String>]
  164. def extract_field_names(parsed)
  165. fields = []
  166. status_change = parsed[:status_change] || {}
  167. rejection = parsed[:rejection_details] || {}
  168. offer = parsed[:offer_details] || {}
  169. feedback = parsed[:feedback] || {}
  170. fields << "status_type" if status_change[:type].present?
  171. fields << "is_final" unless status_change[:is_final].nil?
  172. fields << "sentiment" if parsed[:sentiment].present?
  173. fields << "rejection_reason" if rejection[:reason].present?
  174. fields << "stage_rejected_at" if rejection[:stage_rejected_at].present?
  175. fields << "offer_role" if offer[:role_title].present?
  176. fields << "offer_start_date" if offer[:start_date].present?
  177. fields << "response_deadline" if offer[:response_deadline].present?
  178. fields << "feedback_text" if feedback[:feedback_text].present?
  179. fields
  180. end
  181. # Builds the extraction prompt
  182. #
  183. # @return [String]
  184. def build_prompt
  185. subject = synced_email.subject || "(No subject)"
  186. body = extract_body_content
  187. from_email = synced_email.from_email || ""
  188. from_name = synced_email.from_name || ""
  189. company_name = application.company&.name || synced_email.signal_company_name || ""
  190. current_status = application.pipeline_stage.to_s
  191. vars = {
  192. subject: subject,
  193. body: body.truncate(5000),
  194. from_email: from_email,
  195. from_name: from_name,
  196. company_name: company_name,
  197. current_status: current_status
  198. }
  199. Ai::PromptBuilderService.new(
  200. prompt_class: Ai::StatusExtractionPrompt,
  201. variables: vars
  202. ).run
  203. end
  204. # Extracts body content from email
  205. #
  206. # @return [String]
  207. def extract_body_content
  208. if synced_email.body_preview.present?
  209. synced_email.body_preview
  210. elsif synced_email.body_html.present?
  211. ActionController::Base.helpers.strip_tags(synced_email.body_html)
  212. else
  213. synced_email.snippet || ""
  214. end
  215. end
  216. # Parses LLM response JSON
  217. #
  218. # @param content [String] Raw LLM response
  219. # @return [Hash, nil]
  220. def parse_response(content)
  221. parsed = Ai::ResponseParserService.new(content).parse(symbolize: true)
  222. return parsed if parsed
  223. Rails.logger.warn("[ApplicationStatusProcessor] Failed to parse JSON")
  224. nil
  225. end
  226. # Handles rejection emails
  227. #
  228. # @param data [Hash] Extracted data
  229. # @return [Hash]
  230. def handle_rejection(data)
  231. Rails.logger.info("[ApplicationStatusProcessor] Handling rejection for application ##{application.id}")
  232. # Only update if application is still active
  233. if application.active?
  234. begin
  235. application.reject!
  236. application.move_to_closed! if application.may_move_to_closed?
  237. Rails.logger.info("[ApplicationStatusProcessor] Updated application ##{application.id} to rejected/closed")
  238. rescue AASM::InvalidTransition => e
  239. Rails.logger.warn("[ApplicationStatusProcessor] Could not transition to rejected: #{e.message}")
  240. end
  241. end
  242. # Create company feedback
  243. create_rejection_feedback(data)
  244. { success: true, action: :rejection, application: application }
  245. end
  246. # Handles offer emails
  247. #
  248. # @param data [Hash] Extracted data
  249. # @return [Hash]
  250. def handle_offer(data)
  251. Rails.logger.info("[ApplicationStatusProcessor] Handling offer for application ##{application.id}")
  252. # Move to offer stage
  253. if application.may_move_to_offer?
  254. application.move_to_offer!
  255. Rails.logger.info("[ApplicationStatusProcessor] Moved application ##{application.id} to offer stage")
  256. end
  257. # Create company feedback with offer details
  258. create_offer_feedback(data)
  259. { success: true, action: :offer, application: application }
  260. end
  261. # Handles other status changes (withdrawal, ghosted, on_hold)
  262. #
  263. # @param data [Hash] Extracted data
  264. # @return [Hash]
  265. def handle_other_status(data)
  266. status_change = data[:status_change] || {}
  267. status_type = status_change[:type]
  268. Rails.logger.info("[ApplicationStatusProcessor] Handling #{status_type} for application ##{application.id}")
  269. case status_type
  270. when "withdrawal"
  271. # Company withdrew the position
  272. if application.active?
  273. application.archive! if application.may_archive?
  274. Rails.logger.info("[ApplicationStatusProcessor] Archived application ##{application.id} (withdrawal)")
  275. end
  276. create_generic_feedback(data, "Position withdrawn")
  277. when "ghosted"
  278. # Mark as potentially dead
  279. create_generic_feedback(data, "No response - possible ghost")
  280. when "on_hold"
  281. # Create feedback noting the hold
  282. create_generic_feedback(data, "Position/process on hold")
  283. end
  284. { success: true, action: status_type.to_sym, application: application }
  285. end
  286. # Creates rejection feedback record
  287. #
  288. # @param data [Hash] Extracted data
  289. def create_rejection_feedback(data)
  290. rejection = data[:rejection_details] || {}
  291. feedback = data[:feedback] || {}
  292. feedback_text = feedback[:feedback_text].to_s.strip
  293. feedback_text = rejection[:reason].to_s.strip if feedback_text.blank?
  294. existing_feedback = application.company_feedback
  295. if existing_feedback.present?
  296. update_company_feedback(existing_feedback, feedback_text)
  297. attach_rejection_feedback_to_latest_round(feedback_text)
  298. return
  299. end
  300. fb = CompanyFeedback.create!(
  301. interview_application: application,
  302. source_email_id: synced_email.id,
  303. feedback_type: "rejection",
  304. feedback_text: feedback_text.presence,
  305. rejection_reason: build_rejection_reason(rejection),
  306. received_at: synced_email.email_date || Time.current,
  307. self_reflection: nil, # User can add later
  308. next_steps: rejection[:door_open] ? "Keep in touch for future opportunities" : nil
  309. )
  310. Rails.logger.info("[ApplicationStatusProcessor] Created rejection CompanyFeedback ##{fb.id}")
  311. attach_rejection_feedback_to_latest_round(feedback_text)
  312. rescue ActiveRecord::RecordInvalid => e
  313. Rails.logger.warn("[ApplicationStatusProcessor] Failed to create rejection feedback: #{e.message}")
  314. end
  315. # Updates existing company feedback with new text
  316. #
  317. # @param existing_feedback [CompanyFeedback]
  318. # @param feedback_text [String]
  319. def update_company_feedback(existing_feedback, feedback_text)
  320. return if feedback_text.blank?
  321. existing_text = existing_feedback.feedback_text.to_s.strip
  322. return if existing_text.include?(feedback_text)
  323. updated_text = [ existing_text.presence, feedback_text.presence ].compact.join("\n\n")
  324. existing_feedback.update!(feedback_text: updated_text)
  325. rescue ActiveRecord::RecordInvalid => e
  326. Rails.logger.warn("[ApplicationStatusProcessor] Failed to update rejection feedback: #{e.message}")
  327. end
  328. # Attaches rejection feedback to the latest round (if any)
  329. #
  330. # @param feedback_text [String]
  331. def attach_rejection_feedback_to_latest_round(feedback_text)
  332. return if feedback_text.blank?
  333. round = application.latest_round
  334. return unless round
  335. return if round.interview_feedback.present?
  336. InterviewFeedback.create!(
  337. interview_round: round,
  338. went_well: nil,
  339. to_improve: nil,
  340. ai_summary: nil,
  341. interviewer_notes: feedback_text,
  342. recommended_action: "Review feedback and apply learnings to future interviews"
  343. )
  344. rescue ActiveRecord::RecordInvalid => e
  345. Rails.logger.warn("[ApplicationStatusProcessor] Failed to create round feedback: #{e.message}")
  346. end
  347. # Builds rejection reason text
  348. #
  349. # @param rejection [Hash]
  350. # @return [String]
  351. def build_rejection_reason(rejection)
  352. parts = []
  353. parts << rejection[:reason] if rejection[:reason].present?
  354. parts << "Rejected at: #{rejection[:stage_rejected_at]} stage" if rejection[:stage_rejected_at].present?
  355. parts << "(Generic rejection email)" if rejection[:is_generic]
  356. parts.join("\n")
  357. end
  358. # Creates offer feedback record
  359. #
  360. # @param data [Hash] Extracted data
  361. def create_offer_feedback(data)
  362. offer = data[:offer_details] || {}
  363. feedback = data[:feedback] || {}
  364. # Don't create duplicate feedback
  365. return if application.company_feedback.present?
  366. next_steps = []
  367. next_steps << offer[:next_steps] if offer[:next_steps].present?
  368. next_steps << "Respond by: #{offer[:response_deadline]}" if offer[:response_deadline].present?
  369. next_steps << "Start date: #{offer[:start_date]}" if offer[:start_date].present?
  370. fb = CompanyFeedback.create!(
  371. interview_application: application,
  372. source_email_id: synced_email.id,
  373. feedback_type: "offer",
  374. feedback_text: build_offer_text(offer, feedback),
  375. rejection_reason: nil,
  376. received_at: synced_email.email_date || Time.current,
  377. self_reflection: nil,
  378. next_steps: next_steps.join("\n")
  379. )
  380. Rails.logger.info("[ApplicationStatusProcessor] Created offer CompanyFeedback ##{fb.id}")
  381. rescue ActiveRecord::RecordInvalid => e
  382. Rails.logger.warn("[ApplicationStatusProcessor] Failed to create offer feedback: #{e.message}")
  383. end
  384. # Builds offer text
  385. #
  386. # @param offer [Hash]
  387. # @param feedback [Hash]
  388. # @return [String]
  389. def build_offer_text(offer, feedback)
  390. parts = []
  391. parts << "🎉 Offer received!"
  392. parts << "Role: #{offer[:role_title]}" if offer[:role_title].present?
  393. parts << "Department: #{offer[:department]}" if offer[:department].present?
  394. parts << feedback[:feedback_text] if feedback[:feedback_text].present?
  395. parts.join("\n")
  396. end
  397. # Creates generic feedback record
  398. #
  399. # @param data [Hash]
  400. # @param summary [String]
  401. def create_generic_feedback(data, summary)
  402. feedback = data[:feedback] || {}
  403. # Don't create duplicate feedback
  404. return if application.company_feedback.present?
  405. fb = CompanyFeedback.create!(
  406. interview_application: application,
  407. source_email_id: synced_email.id,
  408. feedback_type: "general",
  409. feedback_text: "#{summary}\n\n#{feedback[:feedback_text]}".strip,
  410. received_at: synced_email.email_date || Time.current
  411. )
  412. Rails.logger.info("[ApplicationStatusProcessor] Created generic CompanyFeedback ##{fb.id}")
  413. rescue ActiveRecord::RecordInvalid => e
  414. Rails.logger.warn("[ApplicationStatusProcessor] Failed to create feedback: #{e.message}")
  415. end
  416. # Returns provider chain for LLM
  417. #
  418. # @return [Array<String>]
  419. def provider_chain
  420. LlmProviders::ProviderConfigHelper.all_providers
  421. end
  422. # Gets provider instance
  423. #
  424. # @param provider_name [String]
  425. # @return [Object, nil]
  426. def get_provider_instance(provider_name)
  427. case provider_name.to_s.downcase
  428. when "openai" then LlmProviders::OpenaiProvider.new
  429. when "anthropic" then LlmProviders::AnthropicProvider.new
  430. when "ollama" then LlmProviders::OllamaProvider.new
  431. else nil
  432. end
  433. end
  434. end
  435. end

app/services/signals/company_feedback_processor.rb

0.0% lines covered

100.0% branches covered

92 relevant lines. 0 lines covered and 92 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Service for capturing company feedback from various email types
  4. #
  5. # This processor creates CompanyFeedback records from emails that contain
  6. # feedback about the candidate, even if they're not explicit rejection/offer emails.
  7. # It works as a secondary processor alongside ApplicationStatusProcessor.
  8. #
  9. # @example
  10. # processor = Signals::CompanyFeedbackProcessor.new(synced_email)
  11. # result = processor.process
  12. # if result[:success]
  13. # # Feedback captured
  14. # end
  15. #
  16. class CompanyFeedbackProcessor < ApplicationService
  17. attr_reader :synced_email, :application
  18. # Initialize the processor
  19. #
  20. # @param synced_email [SyncedEmail] The email to process
  21. def initialize(synced_email)
  22. @synced_email = synced_email
  23. @application = synced_email.interview_application
  24. end
  25. # Processes the email to create company feedback
  26. #
  27. # @return [Hash] Result with success status
  28. def process
  29. return skip_result("Email not matched to application") unless application
  30. return skip_result("Feedback already exists for this email") if feedback_exists_for_email?
  31. return skip_result("No email content") unless content_available?
  32. # Check if there's extractable feedback in the email
  33. feedback_data = extract_feedback_from_signals
  34. if feedback_data[:has_feedback]
  35. feedback = create_feedback_record(feedback_data)
  36. if feedback&.persisted?
  37. { success: true, feedback: feedback, action: :created }
  38. else
  39. { success: false, error: "Failed to create feedback record" }
  40. end
  41. else
  42. skip_result("No feedback content found in email")
  43. end
  44. rescue StandardError => e
  45. notify_error(
  46. e,
  47. context: "company_feedback_processor",
  48. user: synced_email&.user,
  49. synced_email_id: synced_email&.id,
  50. application_id: application&.id
  51. )
  52. { success: false, error: e.message }
  53. end
  54. private
  55. # Checks if email content is available
  56. #
  57. # @return [Boolean]
  58. def content_available?
  59. synced_email.body_preview.present? ||
  60. synced_email.body_html.present? ||
  61. synced_email.snippet.present?
  62. end
  63. # Checks if feedback already exists for this email
  64. #
  65. # @return [Boolean]
  66. def feedback_exists_for_email?
  67. CompanyFeedback.exists?(source_email_id: synced_email.id)
  68. end
  69. # Returns skip result
  70. #
  71. # @param reason [String]
  72. # @return [Hash]
  73. def skip_result(reason)
  74. { success: false, skipped: true, reason: reason }
  75. end
  76. # Extracts feedback data from already-extracted signals
  77. #
  78. # @return [Hash]
  79. def extract_feedback_from_signals
  80. # Use existing extracted data if available
  81. extracted = synced_email.extracted_data || {}
  82. # Check if there's feedback in the extracted signals
  83. feedback_text = extracted.dig("feedback", "feedback_text") ||
  84. extracted.dig("feedback_text")
  85. key_insights = extracted["key_insights"]
  86. has_feedback = feedback_text.present? || key_insights.present?
  87. {
  88. has_feedback: has_feedback,
  89. feedback_text: feedback_text,
  90. key_insights: key_insights,
  91. feedback_type: determine_feedback_type
  92. }
  93. end
  94. # Determines the feedback type based on email type
  95. #
  96. # @return [String]
  97. def determine_feedback_type
  98. case synced_email.email_type
  99. when "rejection" then "rejection"
  100. when "offer" then "offer"
  101. when "round_feedback" then "general"
  102. else "general"
  103. end
  104. end
  105. # Creates company feedback record
  106. #
  107. # @param data [Hash]
  108. # @return [CompanyFeedback, nil]
  109. def create_feedback_record(data)
  110. # Don't duplicate if feedback exists for the application from the same source
  111. return nil if application.company_feedback.present? && data[:feedback_type] != "general"
  112. feedback_text = build_feedback_text(data)
  113. return nil if feedback_text.blank?
  114. CompanyFeedback.create!(
  115. interview_application: application,
  116. source_email_id: synced_email.id,
  117. feedback_type: data[:feedback_type],
  118. feedback_text: feedback_text,
  119. received_at: synced_email.received_at || Time.current
  120. )
  121. rescue ActiveRecord::RecordInvalid => e
  122. Rails.logger.warn("CompanyFeedbackProcessor: Failed to create feedback: #{e.message}")
  123. nil
  124. end
  125. # Builds feedback text from extracted data
  126. #
  127. # @param data [Hash]
  128. # @return [String]
  129. def build_feedback_text(data)
  130. parts = []
  131. if data[:feedback_text].present?
  132. parts << data[:feedback_text]
  133. end
  134. if data[:key_insights].present? && data[:feedback_text].blank?
  135. parts << "Key Insights:\n#{data[:key_insights]}"
  136. end
  137. parts.join("\n\n").strip
  138. end
  139. end
  140. end

app/services/signals/email_state_orchestrator.rb

0.0% lines covered

100.0% branches covered

70 relevant lines. 0 lines covered and 70 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. require "set"
  3. module Signals
  4. # Orchestrates processor execution and application state updates.
  5. class EmailStateOrchestrator < ApplicationService
  6. PROCESSOR_ACTIONS = %i[
  7. run_interview_round_processor
  8. run_round_feedback_processor
  9. run_status_processor
  10. ].freeze
  11. attr_reader :synced_email, :context, :planner
  12. def initialize(synced_email)
  13. @synced_email = synced_email
  14. @context = Signals::StateContext.new(synced_email)
  15. @planner = Signals::StateTransitionPlanner.new(context)
  16. end
  17. def call
  18. unless context.matched?
  19. log_info("Skipped email #{synced_email.id}: not matched to application")
  20. return { success: false, skipped: true, reason: "Email not matched to application" }
  21. end
  22. log_info("Processing email #{synced_email.id} (type: #{synced_email.email_type})")
  23. actions = planner.plan
  24. processor_actions, state_actions = partition_actions(actions)
  25. processor_results = run_processors(processor_actions)
  26. applied_actions = Signals::ActionApplier.new(context).apply!(state_actions)
  27. {
  28. success: true,
  29. actions: actions,
  30. processor_results: processor_results,
  31. applied_actions: applied_actions
  32. }
  33. rescue StandardError => e
  34. notify_error(
  35. e,
  36. context: "signal_email_orchestrator",
  37. user: synced_email&.user,
  38. synced_email_id: synced_email&.id,
  39. application_id: context.application&.id,
  40. email_type: synced_email&.email_type
  41. )
  42. log_error("Failed to process email #{synced_email&.id}: #{e.message}")
  43. { success: false, error: e.message }
  44. end
  45. private
  46. def partition_actions(actions)
  47. processor_actions = actions.select { |action| PROCESSOR_ACTIONS.include?(action[:type]) }
  48. state_actions = actions.reject { |action| PROCESSOR_ACTIONS.include?(action[:type]) }
  49. [ dedupe_actions(processor_actions), state_actions ]
  50. end
  51. def dedupe_actions(actions)
  52. seen = Set.new
  53. actions.each_with_object([]) do |action, list|
  54. next if seen.include?(action[:type])
  55. seen << action[:type]
  56. list << action
  57. end
  58. end
  59. def run_processors(actions)
  60. actions.each_with_object({}) do |action, results|
  61. case action[:type]
  62. when :run_interview_round_processor
  63. results[:interview_round] = Signals::InterviewRoundProcessor.new(synced_email).process
  64. when :run_round_feedback_processor
  65. results[:round_feedback] = Signals::RoundFeedbackProcessor.new(synced_email).process
  66. when :run_status_processor
  67. results[:application_status] = Signals::ApplicationStatusProcessor.new(synced_email).process
  68. end
  69. end
  70. end
  71. end
  72. end

app/services/signals/extraction_service.rb

0.0% lines covered

100.0% branches covered

301 relevant lines. 0 lines covered and 301 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Service for AI-powered extraction of actionable signals from synced emails
  4. #
  5. # Uses configured LLM providers to extract structured intelligence including
  6. # company info, recruiter details, job information, relevant links, and
  7. # suggested actions from email content.
  8. #
  9. # @example
  10. # service = Signals::ExtractionService.new(synced_email)
  11. # result = service.extract
  12. # if result[:success]
  13. # # Email has been updated with extracted signals
  14. # end
  15. #
  16. class ExtractionService < ApplicationService
  17. attr_reader :synced_email
  18. # Minimum confidence score to accept extraction results
  19. MIN_CONFIDENCE_SCORE = 0.5
  20. MIN_PREVIEW_LENGTH = 200
  21. # Initialize the service
  22. #
  23. # @param synced_email [SyncedEmail] The email to extract signals from
  24. def initialize(synced_email)
  25. @synced_email = synced_email
  26. end
  27. # Extracts signals from the email using AI
  28. #
  29. # @return [Hash] Result with success status and extracted data
  30. def extract
  31. return skip_extraction("No email content available") unless email_content_available?
  32. return skip_extraction("Email type not suitable for extraction") unless should_extract?
  33. synced_email.mark_extraction_processing!
  34. # Build prompt with email content
  35. prompt = build_prompt
  36. # Try extraction with LLM providers
  37. result = extract_with_llm(prompt)
  38. if result[:success]
  39. update_email_with_signals(result[:data])
  40. result
  41. else
  42. synced_email.mark_extraction_failed!(result[:error])
  43. { success: false, error: result[:error] || "Extraction failed" }
  44. end
  45. rescue StandardError => e
  46. notify_error(
  47. e,
  48. context: "signal_extraction_service",
  49. user: synced_email&.user,
  50. synced_email_id: synced_email&.id,
  51. email_type: synced_email&.email_type
  52. )
  53. synced_email.mark_extraction_failed!(e.message)
  54. { success: false, error: e.message }
  55. end
  56. private
  57. # Checks if email content is available for extraction
  58. #
  59. # @return [Boolean]
  60. def email_content_available?
  61. synced_email.body_preview.present? ||
  62. synced_email.body_html.present? ||
  63. synced_email.snippet.present? ||
  64. synced_email.subject.present?
  65. end
  66. # Determines if this email should have signals extracted
  67. # Skip extraction for clearly irrelevant emails
  68. #
  69. # @return [Boolean]
  70. def should_extract?
  71. # Skip auto-ignored and ignored emails
  72. return false if synced_email.auto_ignored? || synced_email.ignored?
  73. # Skip emails classified as "other" that aren't matched
  74. return false if synced_email.email_type == "other" && !synced_email.matched?
  75. true
  76. end
  77. # Skips extraction with a reason
  78. #
  79. # @param reason [String]
  80. # @return [Hash]
  81. def skip_extraction(reason)
  82. synced_email.mark_extraction_skipped!
  83. { success: false, skipped: true, reason: reason }
  84. end
  85. # Builds the extraction prompt with email content
  86. #
  87. # @return [String]
  88. def build_prompt
  89. subject = synced_email.subject || "(No subject)"
  90. body = extract_body_content
  91. from_email = synced_email.from_email || ""
  92. from_name = synced_email.from_name || ""
  93. email_type = synced_email.email_type || "unknown"
  94. vars = {
  95. subject: subject,
  96. body: body.truncate(6000),
  97. from_email: from_email,
  98. from_name: from_name,
  99. email_type: email_type
  100. }
  101. Ai::PromptBuilderService.new(
  102. prompt_class: Ai::SignalExtractionPrompt,
  103. variables: vars
  104. ).run
  105. end
  106. # Extracts the best available body content
  107. #
  108. # @return [String]
  109. def extract_body_content
  110. # Prefer preview if it's substantial; otherwise fall back to cleaned HTML text
  111. if synced_email.body_preview.present? && synced_email.body_preview.length >= MIN_PREVIEW_LENGTH
  112. normalize_text(synced_email.body_preview)
  113. elsif synced_email.body_html.present?
  114. normalize_text(extract_text_from_html(synced_email.body_html))
  115. else
  116. normalize_text(synced_email.snippet || "")
  117. end
  118. end
  119. # Extracts data using LLM providers
  120. #
  121. # @param prompt [String] The extraction prompt
  122. # @return [Hash] Result with success and data
  123. def extract_with_llm(prompt)
  124. prompt_template = Ai::SignalExtractionPrompt.active_prompt
  125. system_message = prompt_template&.system_prompt.presence || Ai::SignalExtractionPrompt.default_system_prompt
  126. runner = Ai::ProviderRunnerService.new(
  127. provider_chain: provider_chain,
  128. prompt: prompt,
  129. content_size: extract_body_content.bytesize,
  130. system_message: system_message,
  131. provider_for: method(:get_provider_instance),
  132. run_options: { max_tokens: 2000, temperature: 0.1 },
  133. logger_builder: lambda { |provider_name, provider|
  134. Ai::ApiLoggerService.new(
  135. operation_type: :signal_extraction,
  136. loggable: synced_email,
  137. provider: provider_name,
  138. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  139. llm_prompt: prompt_template
  140. )
  141. },
  142. operation: :signal_extraction,
  143. loggable: synced_email,
  144. user: synced_email&.user,
  145. error_context: {
  146. severity: "warning",
  147. synced_email_id: synced_email&.id,
  148. email_type: synced_email&.email_type
  149. }
  150. )
  151. result = runner.run do |response|
  152. parsed = parse_response(response[:content])
  153. log_data = {
  154. confidence: parsed&.dig(:confidence_score),
  155. company_name: parsed&.dig(:company, :name),
  156. recruiter_name: parsed&.dig(:recruiter, :name),
  157. job_title: parsed&.dig(:job, :title),
  158. suggested_actions: parsed&.dig(:suggested_actions)
  159. }.compact
  160. accept = parsed[:confidence_score] && parsed[:confidence_score] >= MIN_CONFIDENCE_SCORE
  161. [ parsed, log_data, accept ]
  162. end
  163. return { success: true, data: result[:parsed], provider: result[:provider] } if result[:success]
  164. { success: false, error: result[:error] || "All providers failed or returned low confidence" }
  165. end
  166. # Returns the provider chain
  167. #
  168. # @return [Array<String>]
  169. def provider_chain
  170. LlmProviders::ProviderConfigHelper.all_providers
  171. end
  172. # Gets a provider instance
  173. #
  174. # @param provider_name [String]
  175. # @return [LlmProviders::BaseProvider, nil]
  176. def get_provider_instance(provider_name)
  177. case provider_name.to_s.downcase
  178. when "openai"
  179. LlmProviders::OpenaiProvider.new
  180. when "anthropic"
  181. LlmProviders::AnthropicProvider.new
  182. when "ollama"
  183. LlmProviders::OllamaProvider.new
  184. else
  185. nil
  186. end
  187. end
  188. # Parses the LLM response
  189. #
  190. # @param response_text [String]
  191. # @return [Hash]
  192. def parse_response(response_text)
  193. parsed = Ai::ResponseParserService.new(response_text).parse(symbolize: true)
  194. parsed || { confidence_score: 0.0 }
  195. end
  196. # Updates the email with extracted signal data
  197. #
  198. # @param data [Hash] Extracted data from LLM
  199. # @return [void]
  200. def update_email_with_signals(data)
  201. extracted = {}
  202. # Company information
  203. if data[:company].is_a?(Hash)
  204. extracted[:signal_company_name] = data[:company][:name] if data[:company][:name].present?
  205. extracted[:signal_company_website] = data[:company][:website] if data[:company][:website].present?
  206. extracted[:signal_company_careers_url] = data[:company][:careers_url] if data[:company][:careers_url].present?
  207. extracted[:signal_company_domain] = data[:company][:domain] if data[:company][:domain].present?
  208. end
  209. # Recruiter information
  210. if data[:recruiter].is_a?(Hash)
  211. extracted[:signal_recruiter_name] = data[:recruiter][:name] if data[:recruiter][:name].present?
  212. extracted[:signal_recruiter_email] = data[:recruiter][:email] if data[:recruiter][:email].present?
  213. extracted[:signal_recruiter_title] = data[:recruiter][:title] if data[:recruiter][:title].present?
  214. extracted[:signal_recruiter_linkedin] = data[:recruiter][:linkedin_url] if data[:recruiter][:linkedin_url].present?
  215. end
  216. # Job information
  217. if data[:job].is_a?(Hash)
  218. extracted[:signal_job_title] = data[:job][:title] if data[:job][:title].present?
  219. extracted[:signal_job_department] = data[:job][:department] if data[:job][:department].present?
  220. extracted[:signal_job_location] = data[:job][:location] if data[:job][:location].present?
  221. extracted[:signal_job_url] = data[:job][:url] if data[:job][:url].present?
  222. extracted[:signal_job_salary_hint] = data[:job][:salary_hint] if data[:job][:salary_hint].present?
  223. end
  224. # Action links (LLM-classified URLs with dynamic labels)
  225. if data[:action_links].is_a?(Array)
  226. # Normalize and filter links to remove duplicates/noise
  227. normalized_links = data[:action_links].map do |link|
  228. {
  229. "url" => link[:url].to_s,
  230. "action_label" => link[:action_label].to_s,
  231. "priority" => (link[:priority] || 5).to_i
  232. }
  233. end.select { |link| link["url"].present? && link["action_label"].present? }
  234. extracted[:signal_action_links] = filter_action_links(normalized_links)
  235. end
  236. # Suggested backend actions
  237. if data[:suggested_actions].is_a?(Array)
  238. # Filter to only valid actions
  239. valid_actions = data[:suggested_actions] & SyncedEmail::SUGGESTED_ACTIONS
  240. extracted[:signal_suggested_actions] = valid_actions if valid_actions.any?
  241. end
  242. # Store additional metadata
  243. extracted[:key_insights] = data[:key_insights] if data[:key_insights].present?
  244. extracted[:is_forwarded] = data[:is_forwarded] if data[:is_forwarded].present?
  245. extracted[:raw_extraction] = data
  246. extracted[:extracted_at] = Time.current.iso8601
  247. synced_email.update_extraction!(extracted, confidence: data[:confidence_score])
  248. # Automatically save company and recruiter information
  249. save_company_and_recruiter(extracted)
  250. end
  251. # Automatically saves company and recruiter information from extracted signals
  252. # This builds the recruiter directory and company database without user action
  253. #
  254. # @param extracted [Hash] The extracted signal data
  255. # @return [void]
  256. def save_company_and_recruiter(extracted)
  257. company = nil
  258. # Create or find company if we have a name
  259. if extracted[:signal_company_name].present?
  260. company = find_or_create_company(extracted)
  261. end
  262. # Enrich the email sender with recruiter info
  263. enrich_email_sender(extracted, company)
  264. rescue StandardError => e
  265. # Don't fail extraction if company/recruiter save fails
  266. Rails.logger.warn("Failed to save company/recruiter for email #{synced_email.id}: #{e.message}")
  267. end
  268. # Finds or creates a company from extracted signal data
  269. # Note: Company is a global model, not user-scoped
  270. #
  271. # @param extracted [Hash] The extracted signal data
  272. # @return [Company, nil]
  273. def find_or_create_company(extracted)
  274. return nil unless extracted[:signal_company_name].present?
  275. # Try to find existing company by name (case-insensitive)
  276. existing = Company.where("LOWER(name) = ?", extracted[:signal_company_name].downcase).first
  277. if existing
  278. # Update with any new info we have
  279. updates = {}
  280. updates[:website] = extracted[:signal_company_website] if extracted[:signal_company_website].present? && existing.website.blank?
  281. existing.update!(updates) if updates.any?
  282. return existing
  283. end
  284. # Create new company with extracted data
  285. Company.create!(
  286. name: extracted[:signal_company_name],
  287. website: extracted[:signal_company_website]
  288. )
  289. rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
  290. Rails.logger.warn("Failed to create company #{extracted[:signal_company_name]}: #{e.message}")
  291. # Try to find again in case of race condition
  292. Company.where("LOWER(name) = ?", extracted[:signal_company_name].downcase).first
  293. end
  294. # Enriches the email sender record with extracted recruiter information
  295. #
  296. # @param extracted [Hash] The extracted signal data
  297. # @param company [Company, nil] The associated company
  298. # @return [void]
  299. def enrich_email_sender(extracted, company)
  300. # Get or create the email sender
  301. sender = synced_email.email_sender
  302. sender ||= EmailSender.find_or_create_from_email(
  303. synced_email.from_email,
  304. extracted[:signal_recruiter_name] || synced_email.from_name
  305. )
  306. return unless sender
  307. updates = {}
  308. # Update name if we have a better one from extraction
  309. if extracted[:signal_recruiter_name].present? && sender.name.blank?
  310. updates[:name] = extracted[:signal_recruiter_name]
  311. end
  312. # Update title from extraction (if model supports it)
  313. if extracted[:signal_recruiter_title].present? && sender.respond_to?(:title=)
  314. updates[:title] = extracted[:signal_recruiter_title]
  315. end
  316. # Update LinkedIn URL from extraction (if model supports it)
  317. if extracted[:signal_recruiter_linkedin].present? && sender.respond_to?(:linkedin_url=)
  318. updates[:linkedin_url] = extracted[:signal_recruiter_linkedin]
  319. end
  320. # Set sender type to recruiter if we detect recruiter title
  321. if extracted[:signal_recruiter_title].present? &&
  322. extracted[:signal_recruiter_title].downcase.match?(/recruit|talent|sourcing/i)
  323. updates[:sender_type] = "recruiter"
  324. end
  325. # Link to company if not already linked
  326. if company && !sender.has_company?
  327. updates[:auto_detected_company] = company
  328. end
  329. # Update last seen
  330. updates[:last_seen_at] = Time.current
  331. sender.update!(updates) if updates.any?
  332. # Link sender to email if not already
  333. synced_email.update!(email_sender: sender) unless synced_email.email_sender_id == sender.id
  334. end
  335. # Extracts text content from HTML while removing noisy elements.
  336. #
  337. # @param html [String]
  338. # @return [String]
  339. def extract_text_from_html(html)
  340. fragment = Nokogiri::HTML::DocumentFragment.parse(html)
  341. fragment.css("style, script, noscript, head, title, meta, link").remove
  342. fragment.css("*[style]").each do |node|
  343. style = node["style"].to_s.downcase
  344. node.remove if style.include?("display:none") || style.include?("visibility:hidden")
  345. end
  346. fragment.traverse do |node|
  347. node.remove if node.comment?
  348. end
  349. fragment.text
  350. rescue StandardError
  351. ActionController::Base.helpers.strip_tags(html.to_s)
  352. end
  353. # Normalizes text by collapsing whitespace and trimming.
  354. #
  355. # @param text [String]
  356. # @return [String]
  357. def normalize_text(text)
  358. text.to_s.gsub(/\s+/, " ").strip
  359. end
  360. # Filters action links to remove duplicates and low-value URLs.
  361. #
  362. # @param links [Array<Hash>]
  363. # @return [Array<Hash>]
  364. def filter_action_links(links)
  365. return [] if links.blank?
  366. ignore_label_patterns = [
  367. /unsubscribe/i,
  368. /view in browser/i,
  369. /privacy/i,
  370. /terms/i,
  371. /learn more/i,
  372. /forwarding/i,
  373. /event details/i
  374. ]
  375. ignore_url_patterns = [
  376. %r{calendar\.google\.com}i,
  377. %r{google\.com/calendar}i,
  378. %r{support\.google\.com}i
  379. ]
  380. seen = {}
  381. links.sort_by { |link| link["priority"] || 5 }.each_with_object([]) do |link, filtered|
  382. url = link["url"].to_s.strip
  383. label = link["action_label"].to_s.strip
  384. next if url.blank? || label.blank?
  385. next if ignore_label_patterns.any? { |pattern| label.match?(pattern) }
  386. next if ignore_url_patterns.any? { |pattern| url.match?(pattern) } &&
  387. !label.match?(/schedule|reschedule|join/i)
  388. key = canonical_url_key(url)
  389. next if key.present? && seen[key]
  390. seen[key] = true if key.present?
  391. filtered << link
  392. end
  393. end
  394. # Canonicalizes URLs for deduplication by stripping tracking params.
  395. #
  396. # @param url [String]
  397. # @return [String, nil]
  398. def canonical_url_key(url)
  399. uri = URI.parse(url)
  400. return nil unless uri.host
  401. # Unwrap common redirect URLs (e.g., Google Calendar links)
  402. if uri.host.match?(/google\.com/i) && uri.path == "/url"
  403. params = URI.decode_www_form(uri.query.to_s).to_h
  404. redirected = params["q"] || params["url"]
  405. return canonical_url_key(redirected) if redirected.present?
  406. end
  407. params = URI.decode_www_form(uri.query.to_s).reject do |(key, _)|
  408. key.match?(/\Autm_/i) || %w[gclid fbclid mc_cid mc_eid].include?(key)
  409. end
  410. uri.query = params.any? ? URI.encode_www_form(params) : nil
  411. uri.fragment = nil
  412. normalized = "#{uri.scheme}://#{uri.host}#{uri.path}"
  413. normalized += "?#{uri.query}" if uri.query.present?
  414. normalized.sub(%r{/\z}, "")
  415. rescue StandardError
  416. nil
  417. end
  418. end
  419. end

app/services/signals/interview_round_processor.rb

0.0% lines covered

100.0% branches covered

280 relevant lines. 0 lines covered and 280 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Service for automatically creating interview rounds from scheduling confirmation emails
  4. #
  5. # Processes emails classified as scheduling, interview_invite, or interview_reminder
  6. # to extract interview details and create InterviewRound records.
  7. #
  8. # @example
  9. # processor = Signals::InterviewRoundProcessor.new(synced_email)
  10. # result = processor.process
  11. # if result[:success]
  12. # # Interview round created or updated
  13. # end
  14. #
  15. class InterviewRoundProcessor < ApplicationService
  16. attr_reader :synced_email, :application
  17. # Email types that this processor handles
  18. PROCESSABLE_TYPES = %w[scheduling interview_invite interview_reminder].freeze
  19. # Minimum confidence score to accept extraction results
  20. MIN_CONFIDENCE_SCORE = 0.5
  21. # Operation type for logging
  22. OPERATION_TYPE = :interview_round_extraction
  23. # Initialize the processor
  24. #
  25. # @param synced_email [SyncedEmail] The email to process
  26. def initialize(synced_email)
  27. @synced_email = synced_email
  28. @application = synced_email.interview_application
  29. end
  30. # Processes the email to create/update interview round
  31. #
  32. # @return [Hash] Result with success status and round
  33. def process
  34. Rails.logger.info("[InterviewRoundProcessor] Processing email ##{synced_email.id}: #{synced_email.subject}")
  35. return skip_result("Email not matched to application") unless application
  36. return skip_result("Email type not processable") unless processable?
  37. return skip_result("No email content") unless content_available?
  38. # Check if we already processed this email
  39. existing_round = InterviewRound.find_by(source_email_id: synced_email.id)
  40. if existing_round
  41. Rails.logger.info("[InterviewRoundProcessor] Email ##{synced_email.id} already processed -> Round ##{existing_round.id}")
  42. return skip_result("Already processed", round: existing_round)
  43. end
  44. # Extract interview details using LLM
  45. extraction = extract_interview_data
  46. unless extraction[:success]
  47. Rails.logger.warn("[InterviewRoundProcessor] Extraction failed for email ##{synced_email.id}: #{extraction[:error]}")
  48. return { success: false, error: extraction[:error] }
  49. end
  50. data = extraction[:data]
  51. scheduled_at = parse_scheduled_time(data.dig(:interview, :scheduled_at))
  52. unless scheduling_signal_present?(data, scheduled_at)
  53. return skip_result("Insufficient scheduling signal")
  54. end
  55. # Create or update interview round
  56. round = create_or_update_round(data, scheduled_at)
  57. if round.persisted?
  58. Rails.logger.info("[InterviewRoundProcessor] Created round ##{round.id} for email ##{synced_email.id}")
  59. { success: true, round: round, action: :created, llm_api_log_id: extraction[:llm_api_log_id] }
  60. else
  61. Rails.logger.error("[InterviewRoundProcessor] Failed to persist round: #{round.errors.full_messages.join(', ')}")
  62. { success: false, error: round.errors.full_messages.join(", ") }
  63. end
  64. rescue StandardError => e
  65. notify_error(
  66. e,
  67. context: "interview_round_processor",
  68. user: synced_email&.user,
  69. synced_email_id: synced_email&.id,
  70. application_id: application&.id,
  71. email_type: synced_email&.email_type,
  72. company: application&.company&.name
  73. )
  74. Rails.logger.error("[InterviewRoundProcessor] Error processing email ##{synced_email&.id}: #{e.message}")
  75. { success: false, error: e.message }
  76. end
  77. private
  78. # Checks if email type is processable
  79. #
  80. # @return [Boolean]
  81. def processable?
  82. PROCESSABLE_TYPES.include?(synced_email.email_type)
  83. end
  84. # Checks if email content is available
  85. #
  86. # @return [Boolean]
  87. def content_available?
  88. synced_email.body_preview.present? ||
  89. synced_email.body_html.present? ||
  90. synced_email.snippet.present?
  91. end
  92. # Returns skip result
  93. #
  94. # @param reason [String]
  95. # @param data [Hash] Additional data
  96. # @return [Hash]
  97. def skip_result(reason, data = {})
  98. Rails.logger.info("[InterviewRoundProcessor] Skipped email ##{synced_email&.id}: #{reason}")
  99. { success: false, skipped: true, reason: reason }.merge(data)
  100. end
  101. # Extracts interview data using LLM with observability
  102. #
  103. # @return [Hash] Result with success and data
  104. def extract_interview_data
  105. prompt = build_prompt
  106. prompt_template = Ai::InterviewExtractionPrompt.active_prompt
  107. system_message = prompt_template&.system_prompt.presence || Ai::InterviewExtractionPrompt.default_system_prompt
  108. runner = Ai::ProviderRunnerService.new(
  109. provider_chain: provider_chain,
  110. prompt: prompt,
  111. content_size: extract_body_content.bytesize,
  112. system_message: system_message,
  113. provider_for: method(:get_provider_instance),
  114. run_options: { max_tokens: 1500, temperature: 0.1 },
  115. logger_builder: lambda { |provider_name, provider|
  116. Ai::ApiLoggerService.new(
  117. operation_type: OPERATION_TYPE,
  118. loggable: synced_email,
  119. provider: provider_name,
  120. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  121. llm_prompt: prompt_template
  122. )
  123. },
  124. operation: OPERATION_TYPE,
  125. loggable: synced_email,
  126. user: synced_email&.user,
  127. error_context: {
  128. severity: "warning",
  129. synced_email_id: synced_email&.id,
  130. application_id: application&.id
  131. }
  132. )
  133. result = runner.run do |response|
  134. parsed = parse_response(response[:content])
  135. interview = parsed[:interview] || {}
  136. interviewer = parsed[:interviewer] || {}
  137. logistics = parsed[:logistics] || {}
  138. log_data = {
  139. confidence: parsed&.dig(:confidence_score),
  140. scheduled_at: interview[:scheduled_at],
  141. duration_minutes: interview[:duration_minutes],
  142. stage: interview[:stage],
  143. interviewer_name: interviewer[:name],
  144. video_link: logistics[:video_link].present?,
  145. confirmation_source: parsed[:confirmation_source],
  146. extracted_fields: extract_field_names(parsed)
  147. }.compact
  148. confidence_score = parsed[:confidence_score]
  149. if confidence_score && confidence_score < MIN_CONFIDENCE_SCORE
  150. Rails.logger.warn("[InterviewRoundProcessor] Low confidence (#{confidence_score}) from provider")
  151. end
  152. accept = confidence_score.nil? || confidence_score >= MIN_CONFIDENCE_SCORE
  153. [ parsed, log_data, accept ]
  154. end
  155. return { success: false, error: "Failed to extract interview data from email" } unless result[:success]
  156. Rails.logger.info("[InterviewRoundProcessor] Successfully extracted with #{result[:provider]} (confidence: #{result[:parsed]&.dig(:confidence_score)})")
  157. {
  158. success: true,
  159. data: result[:parsed],
  160. provider: result[:provider],
  161. llm_api_log_id: result[:llm_api_log_id],
  162. latency_ms: result[:latency_ms]
  163. }
  164. end
  165. # Extracts field names that were populated
  166. #
  167. # @param parsed [Hash]
  168. # @return [Array<String>]
  169. def extract_field_names(parsed)
  170. fields = []
  171. interview = parsed[:interview] || {}
  172. interviewer = parsed[:interviewer] || {}
  173. logistics = parsed[:logistics] || {}
  174. fields << "scheduled_at" if interview[:scheduled_at].present?
  175. fields << "duration_minutes" if interview[:duration_minutes].present?
  176. fields << "stage" if interview[:stage].present?
  177. fields << "interviewer_name" if interviewer[:name].present?
  178. fields << "interviewer_role" if interviewer[:role].present?
  179. fields << "video_link" if logistics[:video_link].present?
  180. fields << "confirmation_source" if parsed[:confirmation_source].present?
  181. fields
  182. end
  183. # Builds the extraction prompt
  184. #
  185. # @return [String]
  186. def build_prompt
  187. subject = synced_email.subject || "(No subject)"
  188. body = extract_body_content
  189. from_email = synced_email.from_email || ""
  190. from_name = synced_email.from_name || ""
  191. company_name = application.company&.name || synced_email.signal_company_name || ""
  192. vars = {
  193. subject: subject,
  194. body: body.truncate(5000),
  195. from_email: from_email,
  196. from_name: from_name,
  197. company_name: company_name
  198. }
  199. Ai::PromptBuilderService.new(
  200. prompt_class: Ai::InterviewExtractionPrompt,
  201. variables: vars
  202. ).run
  203. end
  204. # Extracts body content from email
  205. #
  206. # @return [String]
  207. def extract_body_content
  208. if synced_email.body_preview.present?
  209. synced_email.body_preview
  210. elsif synced_email.body_html.present?
  211. ActionController::Base.helpers.strip_tags(synced_email.body_html)
  212. else
  213. synced_email.snippet || ""
  214. end
  215. end
  216. # Parses LLM response JSON
  217. #
  218. # @param content [String] Raw LLM response
  219. # @return [Hash, nil]
  220. def parse_response(content)
  221. parsed = Ai::ResponseParserService.new(content).parse(symbolize: true)
  222. return parsed if parsed
  223. Rails.logger.warn("[InterviewRoundProcessor] Failed to parse JSON")
  224. nil
  225. end
  226. # Creates or updates interview round from extracted data
  227. #
  228. # @param data [Hash] Extracted interview data
  229. # @param scheduled_at [DateTime, nil]
  230. # @return [InterviewRound]
  231. def create_or_update_round(data, scheduled_at)
  232. interview_data = data[:interview] || {}
  233. # Determine stage
  234. stage = map_stage(interview_data[:stage])
  235. # Find existing round by scheduled time (within 1 hour window)
  236. existing = find_existing_round(scheduled_at, stage) if scheduled_at
  237. if existing
  238. update_existing_round(existing, data, scheduled_at)
  239. else
  240. create_new_round(data, scheduled_at, stage)
  241. end
  242. end
  243. # Checks if extracted data indicates a true scheduling signal
  244. #
  245. # @param data [Hash]
  246. # @param scheduled_at [DateTime, nil]
  247. # @return [Boolean]
  248. def scheduling_signal_present?(data, scheduled_at)
  249. logistics_data = data[:logistics] || {}
  250. confirmation_source = data[:confirmation_source].to_s
  251. return true if scheduled_at.present?
  252. return true if logistics_data[:video_link].present?
  253. return true if data[:is_rescheduled] || data[:is_cancelled]
  254. # Do not treat scheduling links alone as confirmation.
  255. # Requests to schedule (Calendly/GoodTime links) often lack a confirmed time.
  256. false
  257. end
  258. # Finds existing round by scheduled time
  259. #
  260. # @param scheduled_at [DateTime]
  261. # @return [InterviewRound, nil]
  262. def find_existing_round(scheduled_at, stage)
  263. return nil unless scheduled_at
  264. matched_by_time = application.interview_rounds
  265. .where(scheduled_at: (scheduled_at - 1.hour)..(scheduled_at + 1.hour))
  266. .first
  267. return matched_by_time if matched_by_time
  268. # Fallback: if we previously created an unscheduled round, update it
  269. application.interview_rounds
  270. .where(scheduled_at: nil, stage: stage.to_s)
  271. .where("created_at >= ?", 14.days.ago)
  272. .order(created_at: :desc)
  273. .first
  274. end
  275. # Updates existing interview round
  276. #
  277. # @param round [InterviewRound]
  278. # @param data [Hash]
  279. # @return [InterviewRound]
  280. def update_existing_round(round, data, scheduled_at)
  281. interview_data = data[:interview] || {}
  282. interviewer_data = data[:interviewer] || {}
  283. logistics_data = data[:logistics] || {}
  284. updates = {}
  285. updates[:scheduled_at] = scheduled_at if scheduled_at.present? && round.scheduled_at.blank?
  286. updates[:video_link] = logistics_data[:video_link] if logistics_data[:video_link].present?
  287. updates[:source_email_id] = synced_email.id
  288. updates[:confirmation_source] = data[:confirmation_source] if data[:confirmation_source].present?
  289. updates[:interviewer_name] = interviewer_data[:name] if interviewer_data[:name].present? && round.interviewer_name.blank?
  290. updates[:interviewer_role] = interviewer_data[:role] if interviewer_data[:role].present? && round.interviewer_role.blank?
  291. updates[:duration_minutes] = interview_data[:duration_minutes] if interview_data[:duration_minutes].present? && round.duration_minutes.blank?
  292. if updates.any?
  293. round.update!(updates)
  294. Rails.logger.info("[InterviewRoundProcessor] Updated existing round ##{round.id}")
  295. end
  296. round
  297. end
  298. # Creates new interview round
  299. #
  300. # @param data [Hash]
  301. # @param scheduled_at [DateTime]
  302. # @param stage [Symbol]
  303. # @return [InterviewRound]
  304. def create_new_round(data, scheduled_at, stage)
  305. interview_data = data[:interview] || {}
  306. interviewer_data = data[:interviewer] || {}
  307. logistics_data = data[:logistics] || {}
  308. # Calculate position
  309. position = application.interview_rounds.maximum(:position).to_i + 1
  310. application.interview_rounds.create!(
  311. stage: stage,
  312. stage_name: interview_data[:stage_name],
  313. scheduled_at: scheduled_at,
  314. duration_minutes: interview_data[:duration_minutes] || 30,
  315. interviewer_name: interviewer_data[:name],
  316. interviewer_role: interviewer_data[:role],
  317. video_link: logistics_data[:video_link],
  318. source_email_id: synced_email.id,
  319. confirmation_source: data[:confirmation_source],
  320. position: position,
  321. result: :pending,
  322. notes: build_round_notes(data)
  323. )
  324. end
  325. # Builds notes for the interview round
  326. #
  327. # @param data [Hash]
  328. # @return [String, nil]
  329. def build_round_notes(data)
  330. notes = []
  331. logistics = data[:logistics] || {}
  332. notes << "📬 Created from email signal" if synced_email.present?
  333. notes << "📍 Location: #{logistics[:location]}" if logistics[:location].present?
  334. notes << "📞 Phone: #{logistics[:phone_number]}" if logistics[:phone_number].present?
  335. notes << "🔑 Meeting ID: #{logistics[:meeting_id]}" if logistics[:meeting_id].present?
  336. notes << "🔐 Passcode: #{logistics[:passcode]}" if logistics[:passcode].present?
  337. notes << "📝 #{data[:additional_instructions]}" if data[:additional_instructions].present?
  338. notes.any? ? notes.join("\n") : nil
  339. end
  340. # Parses scheduled time from various formats
  341. #
  342. # @param time_str [String]
  343. # @return [DateTime, nil]
  344. def parse_scheduled_time(time_str)
  345. return nil if time_str.blank?
  346. DateTime.parse(time_str)
  347. rescue ArgumentError, TypeError => e
  348. Rails.logger.warn("[InterviewRoundProcessor] Failed to parse time '#{time_str}': #{e.message}")
  349. nil
  350. end
  351. # Maps extracted stage to InterviewRound stage enum
  352. #
  353. # @param stage_str [String]
  354. # @return [Symbol]
  355. def map_stage(stage_str)
  356. case stage_str&.downcase
  357. when "screening" then :screening
  358. when "technical" then :technical
  359. when "hiring_manager" then :hiring_manager
  360. when "culture_fit" then :culture_fit
  361. else :other
  362. end
  363. end
  364. # Returns provider chain for LLM
  365. #
  366. # @return [Array<String>]
  367. def provider_chain
  368. LlmProviders::ProviderConfigHelper.all_providers
  369. end
  370. # Gets provider instance
  371. #
  372. # @param provider_name [String]
  373. # @return [Object, nil]
  374. def get_provider_instance(provider_name)
  375. case provider_name.to_s.downcase
  376. when "openai" then LlmProviders::OpenaiProvider.new
  377. when "anthropic" then LlmProviders::AnthropicProvider.new
  378. when "ollama" then LlmProviders::OllamaProvider.new
  379. else nil
  380. end
  381. end
  382. end
  383. end

app/services/signals/round_feedback_processor.rb

0.0% lines covered

100.0% branches covered

329 relevant lines. 0 lines covered and 329 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Service for processing round feedback emails to update interview round results
  4. #
  5. # Processes emails classified as round_feedback to extract pass/fail results
  6. # and create InterviewFeedback records with detailed feedback.
  7. #
  8. # @example
  9. # processor = Signals::RoundFeedbackProcessor.new(synced_email)
  10. # result = processor.process
  11. # if result[:success]
  12. # # Round result updated and feedback created
  13. # end
  14. #
  15. class RoundFeedbackProcessor < ApplicationService
  16. attr_reader :synced_email, :application
  17. # Email types that this processor handles
  18. PROCESSABLE_TYPES = %w[round_feedback].freeze
  19. # Minimum confidence score to accept extraction results
  20. MIN_CONFIDENCE_SCORE = 0.5
  21. # Operation type for logging
  22. OPERATION_TYPE = :round_feedback_extraction
  23. # Initialize the processor
  24. #
  25. # @param synced_email [SyncedEmail] The email to process
  26. def initialize(synced_email)
  27. @synced_email = synced_email
  28. @application = synced_email.interview_application
  29. end
  30. # Processes the email to update round result and create feedback
  31. #
  32. # @return [Hash] Result with success status
  33. def process
  34. Rails.logger.info("[RoundFeedbackProcessor] Processing email ##{synced_email.id}: #{synced_email.subject}")
  35. return skip_result("Email not matched to application") unless application
  36. return skip_result("Email type not processable") unless processable?
  37. return skip_result("No email content") unless content_available?
  38. # Extract feedback data using LLM
  39. extraction = extract_feedback_data
  40. unless extraction[:success]
  41. Rails.logger.warn("[RoundFeedbackProcessor] Extraction failed for email ##{synced_email.id}: #{extraction[:error]}")
  42. return { success: false, error: extraction[:error] }
  43. end
  44. data = extraction[:data]
  45. # Find matching round
  46. round = find_matching_round(data)
  47. if round
  48. update_round_with_feedback(round, data)
  49. Rails.logger.info("[RoundFeedbackProcessor] Updated round ##{round.id} result to #{round.result}")
  50. { success: true, round: round, action: :updated, llm_api_log_id: extraction[:llm_api_log_id] }
  51. else
  52. # Create a new round with the feedback result if we couldn't match
  53. round = create_round_from_feedback(data)
  54. if round&.persisted?
  55. Rails.logger.info("[RoundFeedbackProcessor] Created round ##{round.id} from feedback")
  56. { success: true, round: round, action: :created, llm_api_log_id: extraction[:llm_api_log_id] }
  57. else
  58. Rails.logger.warn("[RoundFeedbackProcessor] Could not find or create matching round")
  59. { success: false, error: "Could not find or create matching round" }
  60. end
  61. end
  62. rescue StandardError => e
  63. notify_error(
  64. e,
  65. context: "round_feedback_processor",
  66. user: synced_email&.user,
  67. synced_email_id: synced_email&.id,
  68. application_id: application&.id,
  69. email_type: synced_email&.email_type,
  70. company: application&.company&.name
  71. )
  72. Rails.logger.error("[RoundFeedbackProcessor] Error processing email ##{synced_email&.id}: #{e.message}")
  73. { success: false, error: e.message }
  74. end
  75. private
  76. # Checks if email type is processable
  77. #
  78. # @return [Boolean]
  79. def processable?
  80. PROCESSABLE_TYPES.include?(synced_email.email_type)
  81. end
  82. # Checks if email content is available
  83. #
  84. # @return [Boolean]
  85. def content_available?
  86. synced_email.body_preview.present? ||
  87. synced_email.body_html.present? ||
  88. synced_email.snippet.present?
  89. end
  90. # Returns skip result
  91. #
  92. # @param reason [String]
  93. # @return [Hash]
  94. def skip_result(reason)
  95. Rails.logger.info("[RoundFeedbackProcessor] Skipped email ##{synced_email&.id}: #{reason}")
  96. { success: false, skipped: true, reason: reason }
  97. end
  98. # Extracts feedback data using LLM with observability
  99. #
  100. # @return [Hash] Result with success and data
  101. def extract_feedback_data
  102. prompt = build_prompt
  103. prompt_template = Ai::RoundFeedbackExtractionPrompt.active_prompt
  104. system_message = prompt_template&.system_prompt.presence || Ai::RoundFeedbackExtractionPrompt.default_system_prompt
  105. runner = Ai::ProviderRunnerService.new(
  106. provider_chain: provider_chain,
  107. prompt: prompt,
  108. content_size: extract_body_content.bytesize,
  109. system_message: system_message,
  110. provider_for: method(:get_provider_instance),
  111. run_options: { max_tokens: 1500, temperature: 0.1 },
  112. logger_builder: lambda { |provider_name, provider|
  113. Ai::ApiLoggerService.new(
  114. operation_type: OPERATION_TYPE,
  115. loggable: synced_email,
  116. provider: provider_name,
  117. model: provider.respond_to?(:model_name) ? provider.model_name : "unknown",
  118. llm_prompt: prompt_template
  119. )
  120. },
  121. operation: OPERATION_TYPE,
  122. loggable: synced_email,
  123. user: synced_email&.user,
  124. error_context: {
  125. severity: "warning",
  126. synced_email_id: synced_email&.id,
  127. application_id: application&.id
  128. }
  129. )
  130. result = runner.run do |response|
  131. parsed = parse_response(response[:content])
  132. round_context = parsed[:round_context] || {}
  133. feedback = parsed[:feedback] || {}
  134. next_steps = parsed[:next_steps] || {}
  135. log_data = {
  136. confidence: parsed&.dig(:confidence_score),
  137. result: parsed[:result],
  138. sentiment: parsed[:sentiment],
  139. stage_mentioned: round_context[:stage_mentioned],
  140. interviewer_mentioned: round_context[:interviewer_mentioned],
  141. has_detailed_feedback: feedback[:has_detailed_feedback],
  142. has_next_round: next_steps[:has_next_round],
  143. extracted_fields: extract_field_names(parsed)
  144. }.compact
  145. confidence_score = parsed[:confidence_score]
  146. if confidence_score && confidence_score < MIN_CONFIDENCE_SCORE
  147. Rails.logger.warn("[RoundFeedbackProcessor] Low confidence (#{confidence_score}) from provider")
  148. end
  149. accept = confidence_score.nil? || confidence_score >= MIN_CONFIDENCE_SCORE
  150. [ parsed, log_data, accept ]
  151. end
  152. return { success: false, error: "Failed to extract feedback data from email" } unless result[:success]
  153. Rails.logger.info("[RoundFeedbackProcessor] Successfully extracted with #{result[:provider]} (confidence: #{result[:parsed]&.dig(:confidence_score)}, result: #{result[:parsed]&.dig(:result)})")
  154. {
  155. success: true,
  156. data: result[:parsed],
  157. provider: result[:provider],
  158. llm_api_log_id: result[:llm_api_log_id],
  159. latency_ms: result[:latency_ms]
  160. }
  161. end
  162. # Extracts field names that were populated
  163. #
  164. # @param parsed [Hash]
  165. # @return [Array<String>]
  166. def extract_field_names(parsed)
  167. fields = []
  168. round_context = parsed[:round_context] || {}
  169. feedback = parsed[:feedback] || {}
  170. next_steps = parsed[:next_steps] || {}
  171. fields << "result" if parsed[:result].present?
  172. fields << "sentiment" if parsed[:sentiment].present?
  173. fields << "stage_mentioned" if round_context[:stage_mentioned].present?
  174. fields << "interviewer_mentioned" if round_context[:interviewer_mentioned].present?
  175. fields << "feedback_summary" if feedback[:summary].present?
  176. fields << "strengths" if feedback[:strengths].present?
  177. fields << "improvements" if feedback[:improvements].present?
  178. fields << "next_round_hint" if next_steps[:next_round_hint].present?
  179. fields
  180. end
  181. # Builds the extraction prompt
  182. #
  183. # @return [String]
  184. def build_prompt
  185. subject = synced_email.subject || "(No subject)"
  186. body = extract_body_content
  187. from_email = synced_email.from_email || ""
  188. from_name = synced_email.from_name || ""
  189. company_name = application.company&.name || synced_email.signal_company_name || ""
  190. recent_rounds = build_recent_rounds_context
  191. vars = {
  192. subject: subject,
  193. body: body.truncate(5000),
  194. from_email: from_email,
  195. from_name: from_name,
  196. company_name: company_name,
  197. recent_rounds: recent_rounds
  198. }
  199. Ai::PromptBuilderService.new(
  200. prompt_class: Ai::RoundFeedbackExtractionPrompt,
  201. variables: vars
  202. ).run
  203. end
  204. # Builds context about recent interview rounds
  205. #
  206. # @return [String] JSON array of recent rounds
  207. def build_recent_rounds_context
  208. rounds = application.interview_rounds.order(scheduled_at: :desc).limit(5)
  209. rounds_data = rounds.map do |round|
  210. {
  211. id: round.id,
  212. stage: round.stage,
  213. stage_name: round.stage_name,
  214. scheduled_at: round.scheduled_at&.iso8601,
  215. interviewer_name: round.interviewer_name,
  216. result: round.result
  217. }
  218. end
  219. JSON.pretty_generate(rounds_data)
  220. end
  221. # Extracts body content from email
  222. #
  223. # @return [String]
  224. def extract_body_content
  225. if synced_email.body_preview.present?
  226. synced_email.body_preview
  227. elsif synced_email.body_html.present?
  228. ActionController::Base.helpers.strip_tags(synced_email.body_html)
  229. else
  230. synced_email.snippet || ""
  231. end
  232. end
  233. # Parses LLM response JSON
  234. #
  235. # @param content [String] Raw LLM response
  236. # @return [Hash, nil]
  237. def parse_response(content)
  238. parsed = Ai::ResponseParserService.new(content).parse(symbolize: true)
  239. return parsed if parsed
  240. Rails.logger.warn("[RoundFeedbackProcessor] Failed to parse JSON")
  241. nil
  242. end
  243. # Finds the matching interview round for this feedback
  244. #
  245. # @param data [Hash] Extracted feedback data
  246. # @return [InterviewRound, nil]
  247. def find_matching_round(data)
  248. round_context = data[:round_context] || {}
  249. # Strategy 1: Match by interviewer name
  250. if round_context[:interviewer_mentioned].present?
  251. round = application.interview_rounds
  252. .where("interviewer_name ILIKE ?", "%#{round_context[:interviewer_mentioned]}%")
  253. .where(result: :pending)
  254. .order(scheduled_at: :desc)
  255. .first
  256. if round
  257. Rails.logger.info("[RoundFeedbackProcessor] Matched round ##{round.id} by interviewer name")
  258. return round
  259. end
  260. end
  261. # Strategy 2: Match by stage/type mentioned
  262. if round_context[:stage_mentioned].present?
  263. stage = infer_stage_from_text(round_context[:stage_mentioned])
  264. if stage
  265. round = application.interview_rounds
  266. .where(stage: stage, result: :pending)
  267. .order(scheduled_at: :desc)
  268. .first
  269. if round
  270. Rails.logger.info("[RoundFeedbackProcessor] Matched round ##{round.id} by stage")
  271. return round
  272. end
  273. end
  274. end
  275. # Strategy 3: Most recent pending round
  276. round = application.interview_rounds
  277. .where(result: :pending)
  278. .order(scheduled_at: :desc)
  279. .first
  280. Rails.logger.info("[RoundFeedbackProcessor] Matched round ##{round&.id || 'none'} by most recent pending")
  281. round
  282. end
  283. # Infers stage enum from text description
  284. #
  285. # @param text [String]
  286. # @return [Symbol, nil]
  287. def infer_stage_from_text(text)
  288. text_lower = text.downcase
  289. return :screening if text_lower.match?(/screen|phone|initial|intro/)
  290. return :technical if text_lower.match?(/technical|coding|system design|live coding/)
  291. return :hiring_manager if text_lower.match?(/hiring manager|manager|lead/)
  292. return :culture_fit if text_lower.match?(/culture|behavioral|values|team fit/)
  293. nil
  294. end
  295. # Updates existing round with feedback
  296. #
  297. # @param round [InterviewRound]
  298. # @param data [Hash]
  299. def update_round_with_feedback(round, data)
  300. # Update round result
  301. result = map_result(data[:result])
  302. round.update!(
  303. result: result,
  304. completed_at: Time.current,
  305. source_email_id: synced_email.id
  306. )
  307. # Create interview feedback if detailed feedback exists
  308. if data.dig(:feedback, :has_detailed_feedback)
  309. create_interview_feedback(round, data)
  310. end
  311. end
  312. # Creates a new round with the feedback result
  313. # Used when we receive feedback but don't have a matching round
  314. #
  315. # @param data [Hash]
  316. # @return [InterviewRound, nil]
  317. def create_round_from_feedback(data)
  318. existing = attach_feedback_to_latest_round(data)
  319. return existing if existing
  320. round_context = data[:round_context] || {}
  321. stage = infer_stage_from_text(round_context[:stage_mentioned] || "") || :other
  322. result = map_result(data[:result])
  323. position = application.interview_rounds.maximum(:position).to_i + 1
  324. round = application.interview_rounds.create!(
  325. stage: stage,
  326. stage_name: round_context[:stage_mentioned],
  327. result: result,
  328. completed_at: Time.current,
  329. source_email_id: synced_email.id,
  330. interviewer_name: round_context[:interviewer_mentioned],
  331. position: position,
  332. notes: "📬 Created from feedback email"
  333. )
  334. create_interview_feedback(round, data) if data.dig(:feedback, :has_detailed_feedback)
  335. round
  336. rescue ActiveRecord::RecordInvalid => e
  337. Rails.logger.warn("[RoundFeedbackProcessor] Failed to create round: #{e.message}")
  338. nil
  339. end
  340. # Attaches feedback to latest round when app is already rejected
  341. #
  342. # @param data [Hash]
  343. # @return [InterviewRound, nil]
  344. def attach_feedback_to_latest_round(data)
  345. return nil unless application&.rejected?
  346. return nil unless data.dig(:feedback, :has_detailed_feedback)
  347. round_context = data[:round_context] || {}
  348. has_matching_signal = round_context[:stage_mentioned].present? ||
  349. round_context[:interviewer_mentioned].present? ||
  350. round_context[:date_mentioned].present?
  351. return nil if has_matching_signal
  352. round = application.latest_round
  353. return nil unless round
  354. return round if round.interview_feedback.present?
  355. create_interview_feedback(round, data)
  356. round
  357. end
  358. # Creates interview feedback record
  359. #
  360. # @param round [InterviewRound]
  361. # @param data [Hash]
  362. def create_interview_feedback(round, data)
  363. feedback_data = data[:feedback] || {}
  364. # Don't create duplicate feedback
  365. return if round.interview_feedback.present?
  366. feedback = InterviewFeedback.create!(
  367. interview_round: round,
  368. went_well: Array(feedback_data[:strengths]).join("\n• "),
  369. to_improve: Array(feedback_data[:improvements]).join("\n• "),
  370. ai_summary: feedback_data[:summary],
  371. interviewer_notes: feedback_data[:full_feedback_text],
  372. recommended_action: determine_recommended_action(data)
  373. )
  374. Rails.logger.info("[RoundFeedbackProcessor] Created InterviewFeedback ##{feedback.id} for round ##{round.id}")
  375. rescue ActiveRecord::RecordInvalid => e
  376. Rails.logger.warn("[RoundFeedbackProcessor] Failed to create feedback: #{e.message}")
  377. end
  378. # Determines recommended action based on feedback
  379. #
  380. # @param data [Hash]
  381. # @return [String, nil]
  382. def determine_recommended_action(data)
  383. case data[:result]
  384. when "passed"
  385. next_steps = data[:next_steps] || {}
  386. if next_steps[:has_next_round]
  387. "Prepare for #{next_steps[:next_round_type] || 'next round'}"
  388. else
  389. "Follow up on next steps"
  390. end
  391. when "failed"
  392. "Review feedback and apply learnings to future interviews"
  393. when "waitlisted"
  394. "Follow up in 1-2 weeks if no update"
  395. else
  396. nil
  397. end
  398. end
  399. # Maps result string to InterviewRound result enum
  400. #
  401. # @param result_str [String]
  402. # @return [Symbol]
  403. def map_result(result_str)
  404. case result_str&.downcase
  405. when "passed" then :passed
  406. when "failed" then :failed
  407. when "waitlisted" then :waitlisted
  408. else :pending
  409. end
  410. end
  411. # Returns provider chain for LLM
  412. #
  413. # @return [Array<String>]
  414. def provider_chain
  415. LlmProviders::ProviderConfigHelper.all_providers
  416. end
  417. # Gets provider instance
  418. #
  419. # @param provider_name [String]
  420. # @return [Object, nil]
  421. def get_provider_instance(provider_name)
  422. case provider_name.to_s.downcase
  423. when "openai" then LlmProviders::OpenaiProvider.new
  424. when "anthropic" then LlmProviders::AnthropicProvider.new
  425. when "ollama" then LlmProviders::OllamaProvider.new
  426. else nil
  427. end
  428. end
  429. end
  430. end

app/services/signals/rules/application_confirmation_rule.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Rules
  4. class ApplicationConfirmationRule < BaseRule
  5. PRIORITY = 20
  6. def applies?(context)
  7. context.email_type == "application_confirmation"
  8. end
  9. def actions(_context)
  10. [
  11. { type: :set_pipeline_stage, stage: :applied }
  12. ]
  13. end
  14. end
  15. end
  16. end

app/services/signals/rules/base_rule.rb

0.0% lines covered

100.0% branches covered

46 relevant lines. 0 lines covered and 46 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Rules
  4. # Base rule for signal processing.
  5. class BaseRule < ApplicationService
  6. DEFAULT_PRIORITY = 0
  7. def priority
  8. self.class::PRIORITY
  9. rescue NameError
  10. DEFAULT_PRIORITY
  11. end
  12. def safe_applies?(context)
  13. applies?(context)
  14. rescue StandardError => e
  15. notify_error(
  16. e,
  17. context: "signal_rule_applies",
  18. user: context.synced_email&.user,
  19. synced_email_id: context.synced_email&.id,
  20. application_id: context.application&.id,
  21. rule: self.class.name
  22. )
  23. log_error("Rule #{self.class.name} applies? failed: #{e.message}")
  24. false
  25. end
  26. def safe_actions(context)
  27. actions(context)
  28. rescue StandardError => e
  29. notify_error(
  30. e,
  31. context: "signal_rule_actions",
  32. user: context.synced_email&.user,
  33. synced_email_id: context.synced_email&.id,
  34. application_id: context.application&.id,
  35. rule: self.class.name
  36. )
  37. log_error("Rule #{self.class.name} actions failed: #{e.message}")
  38. []
  39. end
  40. def applies?(_context)
  41. false
  42. end
  43. def actions(_context)
  44. []
  45. end
  46. end
  47. end
  48. end

app/services/signals/rules/offer_rule.rb

0.0% lines covered

100.0% branches covered

15 relevant lines. 0 lines covered and 15 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Rules
  4. class OfferRule < BaseRule
  5. PRIORITY = 80
  6. def applies?(context)
  7. context.email_type == "offer"
  8. end
  9. def actions(_context)
  10. [
  11. { type: :run_status_processor }
  12. ]
  13. end
  14. end
  15. end
  16. end

app/services/signals/rules/rejection_rule.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Rules
  4. class RejectionRule < BaseRule
  5. PRIORITY = 100
  6. def applies?(context)
  7. context.email_type == "rejection"
  8. end
  9. def actions(_context)
  10. [
  11. { type: :run_status_processor },
  12. { type: :mark_latest_round_failed }
  13. ]
  14. end
  15. end
  16. end
  17. end

app/services/signals/rules/round_feedback_rule.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Rules
  4. class RoundFeedbackRule < BaseRule
  5. PRIORITY = 70
  6. def applies?(context)
  7. context.email_type == "round_feedback"
  8. end
  9. def actions(_context)
  10. [
  11. { type: :run_round_feedback_processor },
  12. { type: :sync_application_from_round_result }
  13. ]
  14. end
  15. end
  16. end
  17. end

app/services/signals/rules/scheduling_rule.rb

0.0% lines covered

100.0% branches covered

17 relevant lines. 0 lines covered and 17 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. module Rules
  4. class SchedulingRule < BaseRule
  5. PRIORITY = 40
  6. PROCESSABLE_TYPES = %w[scheduling interview_invite interview_reminder].freeze
  7. def applies?(context)
  8. PROCESSABLE_TYPES.include?(context.email_type)
  9. end
  10. def actions(_context)
  11. [
  12. { type: :run_interview_round_processor },
  13. { type: :sync_pipeline_from_round_stage }
  14. ]
  15. end
  16. end
  17. end
  18. end

app/services/signals/state_context.rb

0.0% lines covered

100.0% branches covered

16 relevant lines. 0 lines covered and 16 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Normalized context for signal rule evaluation.
  4. class StateContext < ApplicationService
  5. attr_reader :synced_email, :application, :email_type, :extracted_data, :latest_round, :pending_rounds
  6. def initialize(synced_email)
  7. @synced_email = synced_email
  8. @application = synced_email.interview_application
  9. @email_type = synced_email.email_type.to_s
  10. @extracted_data = synced_email.extracted_data || {}
  11. @latest_round = application&.interview_rounds&.ordered&.last
  12. @pending_rounds = application ? application.interview_rounds.where(result: :pending).order(scheduled_at: :desc) : InterviewRound.none
  13. end
  14. def matched?
  15. synced_email.matched?
  16. end
  17. end
  18. end

app/services/signals/state_transition_planner.rb

0.0% lines covered

100.0% branches covered

33 relevant lines. 0 lines covered and 33 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. module Signals
  3. # Evaluates rules and emits ordered actions.
  4. class StateTransitionPlanner < ApplicationService
  5. attr_reader :context, :rules
  6. def initialize(context, rules: default_rules)
  7. @context = context
  8. @rules = rules
  9. end
  10. def plan
  11. applicable = rules.select { |rule| rule.safe_applies?(context) }
  12. applicable.sort_by(&:priority).reverse.flat_map { |rule| rule.safe_actions(context) }
  13. rescue StandardError => e
  14. notify_error(
  15. e,
  16. context: "signal_state_planner",
  17. user: context.synced_email&.user,
  18. synced_email_id: context.synced_email&.id,
  19. application_id: context.application&.id
  20. )
  21. log_error("Failed to plan actions for email #{context.synced_email&.id}: #{e.message}")
  22. []
  23. end
  24. private
  25. def default_rules
  26. [
  27. Rules::RejectionRule.new,
  28. Rules::OfferRule.new,
  29. Rules::RoundFeedbackRule.new,
  30. Rules::SchedulingRule.new,
  31. Rules::ApplicationConfirmationRule.new
  32. ]
  33. end
  34. end
  35. end

gems/admin_suite/app/controllers/admin_suite/application_controller.rb

67.31% lines covered

44.44% branches covered

52 relevant lines. 35 lines covered and 17 lines missed.
18 total branches, 8 branches covered and 10 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 class ApplicationController < ::ApplicationController
  4. 1 include ActionView::RecordIdentifier
  5. # Host apps often include global auth concerns in `ApplicationController`.
  6. # The engine uses `AdminSuite.config.authenticate` instead, so we defensively
  7. # skip any host-level authentication before_actions that would otherwise
  8. # redirect to missing routes (e.g. `new_session_path`).
  9. 1 skip_before_action :require_authentication, raise: false
  10. 1 before_action :admin_suite_authenticate!
  11. 1 layout "admin_suite/application"
  12. 1 helper AdminSuite::BaseHelper
  13. 1 helper_method :admin_suite_actor, :navigation_items
  14. 1 private
  15. # Runs the host-app authentication hook (if configured).
  16. #
  17. # @return [void]
  18. 1 def admin_suite_authenticate!
  19. 7 hook = AdminSuite.config.authenticate
  20. 7 then: 0 else: 7 hook&.call(self)
  21. end
  22. # Returns the configured actor for actions/auditing/authorization.
  23. #
  24. # @return [Object, nil]
  25. 1 def admin_suite_actor
  26. 12 then: 12 else: 0 AdminSuite.config.current_actor&.call(self)
  27. rescue StandardError
  28. nil
  29. end
  30. # Loads resource definition files in development when needed.
  31. #
  32. # @return [void]
  33. 1 def ensure_resources_loaded!
  34. 135 else: 0 then: 135 return unless Rails.env.development?
  35. then: 0 else: 0 return if Admin::Base::Resource.registered_resources.any?
  36. Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
  37. require file
  38. end
  39. rescue NameError
  40. # Ensure base DSL is loaded first.
  41. require "admin/base/resource"
  42. retry
  43. end
  44. # Loads portal definition files in development (safe to call per-request).
  45. #
  46. # @return [void]
  47. 1 def ensure_portals_loaded!
  48. 270 globs = Array(AdminSuite.config.portal_globs).flat_map { |g| Dir[g] }.uniq
  49. 135 then: 0 else: 135 return if globs.empty?
  50. 135 if Rails.env.development?
  51. then: 0 # Re-evaluate definitions on each request in development.
  52. AdminSuite::PortalRegistry.reset!
  53. globs.each { |file| load file }
  54. else
  55. else: 135 # In non-dev, load once (typically at boot / first request).
  56. 135 then: 134 else: 1 return if AdminSuite::PortalRegistry.all.any?
  57. 6 globs.each { |file| require file }
  58. end
  59. rescue NameError
  60. require "admin_suite"
  61. retry
  62. end
  63. # Builds the navigation structure from registered resources.
  64. #
  65. # @return [Hash]
  66. 1 def navigation_items
  67. 135 ensure_resources_loaded!
  68. 135 ensure_portals_loaded!
  69. 135 portals = AdminSuite.config.portals
  70. 135 navigation = portals.each_with_object({}) do |(key, meta), h|
  71. 675 then: 675 else: 0 meta = meta.respond_to?(:symbolize_keys) ? meta.symbolize_keys : {}
  72. 675 h[key.to_sym] = meta.merge(sections: {})
  73. end
  74. # Merge any DSL-defined portal metadata into navigation.
  75. 135 AdminSuite::PortalRegistry.all.each do |key, definition|
  76. 675 navigation[key.to_sym] ||= { label: key.to_s.humanize, order: 100, sections: {} }
  77. 675 navigation[key.to_sym].merge!(definition.to_nav_meta)
  78. 675 navigation[key.to_sym][:sections] ||= {}
  79. end
  80. 135 Admin::Base::Resource.registered_resources.each do |resource|
  81. else: 0 then: 0 next unless resource.portal_name && resource.section_name
  82. portal = resource.portal_name.to_sym
  83. section = resource.section_name.to_sym
  84. navigation[portal] ||= { label: portal.to_s.humanize, order: 100, sections: {} }
  85. navigation[portal][:sections][section] ||= { label: section.to_s.humanize, items: [] }
  86. label = resource.nav_label.presence || resource.human_name_plural
  87. navigation[portal][:sections][section][:items] << {
  88. label: label,
  89. path: resources_path(portal: portal, resource_name: resource.resource_name_plural),
  90. resource: resource,
  91. icon: resource.nav_icon,
  92. order: resource.nav_order
  93. }
  94. end
  95. 135 navigation
  96. end
  97. end
  98. end

gems/admin_suite/app/controllers/admin_suite/dashboard_controller.rb

80.77% lines covered

46.15% branches covered

104 relevant lines. 84 lines covered and 20 lines missed.
52 total branches, 24 branches covered and 28 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 class DashboardController < ApplicationController
  4. 1 def index
  5. # Ensure portal/resource metadata is available.
  6. 3 items = navigation_items
  7. 3 @health = build_root_health
  8. 3 @stats = build_root_stats(items)
  9. 3 @recent = build_root_recent
  10. @portal_cards =
  11. 18 items.sort_by { |(_k, v)| (v[:order] || 100).to_i }.map do |portal_key, portal|
  12. 15 color = portal[:color].presence || default_portal_color(portal_key)
  13. {
  14. 15 key: portal_key,
  15. label: portal[:label] || portal_key.to_s.humanize,
  16. description: portal[:description],
  17. color: color,
  18. icon: portal[:icon],
  19. path: portal_path(portal: portal_key),
  20. count: portal[:sections].values.sum { |s| s[:items].size }
  21. }
  22. end
  23. 3 @dashboard_sections = build_sections
  24. end
  25. 1 private
  26. 1 def default_portal_color(portal_key)
  27. when: 0 case portal_key.to_sym
  28. when: 0 when :ops then "amber"
  29. when: 0 when :email then "emerald"
  30. when: 0 when :ai then "cyan"
  31. when: 0 when :assistant then "violet"
  32. else: 0 when :payments then "emerald"
  33. else "slate"
  34. end
  35. end
  36. 1 def build_sections
  37. 3 sections = []
  38. 3 sections << {
  39. title: "System Health",
  40. subtitle: nil,
  41. rows: [
  42. AdminSuite::UI::RowDefinition.new(panels: [
  43. AdminSuite::UI::PanelDefinition.new(type: :health, title: "Application", options: { span: 3, status: @health.dig(:app, :status), metrics: @health.dig(:app, :metrics) }),
  44. AdminSuite::UI::PanelDefinition.new(type: :health, title: "Scraping Pipeline", options: { span: 3, status: @health.dig(:scraping, :status), metrics: @health.dig(:scraping, :metrics) }),
  45. AdminSuite::UI::PanelDefinition.new(type: :health, title: "LLM API", options: { span: 3, status: @health.dig(:llm, :status), metrics: @health.dig(:llm, :metrics) }),
  46. AdminSuite::UI::PanelDefinition.new(type: :health, title: "Assistant", options: { span: 3, status: @health.dig(:assistant, :status), metrics: @health.dig(:assistant, :metrics) })
  47. ])
  48. ]
  49. }
  50. 3 sections << {
  51. title: nil,
  52. subtitle: nil,
  53. rows: [
  54. AdminSuite::UI::RowDefinition.new(panels: [
  55. AdminSuite::UI::PanelDefinition.new(
  56. type: :cards,
  57. title: "Portals",
  58. options: { span: 12, variant: :portals, resources: @portal_cards }
  59. )
  60. ])
  61. ]
  62. }
  63. 3 sections << {
  64. title: nil,
  65. subtitle: nil,
  66. rows: [
  67. AdminSuite::UI::RowDefinition.new(panels: [
  68. AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Total Resources", options: { span: 3, variant: :mini, color: :slate, value: @stats[:total_resources] }),
  69. AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Ops Resources", options: { span: 3, variant: :mini, color: :amber, value: @stats[:ops_resources] }),
  70. AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Email Resources", options: { span: 2, variant: :mini, color: :emerald, value: @stats[:email_resources] }),
  71. AdminSuite::UI::PanelDefinition.new(type: :stat, title: "AI Resources", options: { span: 2, variant: :mini, color: :cyan, value: @stats[:ai_resources] }),
  72. AdminSuite::UI::PanelDefinition.new(type: :stat, title: "Assistant Resources", options: { span: 2, variant: :mini, color: :violet, value: @stats[:assistant_resources] })
  73. ])
  74. ]
  75. }
  76. 3 sections << {
  77. title: "Recent Activity",
  78. subtitle: nil,
  79. rows: [
  80. AdminSuite::UI::RowDefinition.new(panels: [
  81. 3 AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Signups", options: { span: 3, scope: @recent[:recent_users], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "users") } }),
  82. 3 AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Applications", options: { span: 3, scope: @recent[:recent_applications], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "interview_applications") } }),
  83. 3 AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Assistant", options: { span: 3, scope: @recent[:recent_threads], view_all_path: ->(view) { view.resources_path(portal: :assistant, resource_name: "assistant_threads") } }),
  84. 3 AdminSuite::UI::PanelDefinition.new(type: :recent, title: "Recent Scraping", options: { span: 3, scope: @recent[:recent_scraping], view_all_path: ->(view) { view.resources_path(portal: :ops, resource_name: "scraping_attempts") } })
  85. ])
  86. ]
  87. }
  88. 3 sections
  89. end
  90. 1 def build_root_stats(items)
  91. {
  92. 3 total_resources: Admin::Base::Resource.registered_resources.count,
  93. portals: items.keys.count,
  94. ops_resources: Admin::Base::Resource.resources_for_portal(:ops).count,
  95. email_resources: Admin::Base::Resource.resources_for_portal(:email).count,
  96. ai_resources: Admin::Base::Resource.resources_for_portal(:ai).count,
  97. assistant_resources: Admin::Base::Resource.resources_for_portal(:assistant).count
  98. }
  99. rescue StandardError
  100. { total_resources: 0, portals: 0, ops_resources: 0, email_resources: 0, ai_resources: 0, assistant_resources: 0 }
  101. end
  102. 1 def build_root_recent
  103. {
  104. 6 then: 3 else: 0 recent_users: -> { defined?(::User) ? ::User.order(created_at: :desc).limit(5) : [] },
  105. 3 then: 3 else: 0 recent_applications: -> { defined?(::InterviewApplication) ? ::InterviewApplication.order(created_at: :desc).limit(5) : [] },
  106. 3 then: 3 else: 0 recent_threads: -> { defined?(::Assistant::ChatThread) ? ::Assistant::ChatThread.order(created_at: :desc).limit(5) : [] },
  107. 3 then: 3 else: 0 recent_scraping: -> { defined?(::ScrapingAttempt) ? ::ScrapingAttempt.order(created_at: :desc).limit(5) : [] }
  108. }
  109. end
  110. 1 def build_root_health
  111. {
  112. 3 app: app_health,
  113. scraping: scraping_health,
  114. llm: llm_health,
  115. assistant: assistant_health
  116. }
  117. end
  118. 1 def app_health
  119. 3 else: 3 then: 0 return { status: :unknown, metrics: {} } unless defined?(::User)
  120. metrics = {
  121. 3 "Users" => safe_count(::User),
  122. 3 "24h signups" => safe_count(::User, ->(rel) { rel.where("created_at > ?", 24.hours.ago) }),
  123. 3 then: 3 else: 0 "Applications" => (defined?(::InterviewApplication) ? safe_count(::InterviewApplication) : "—"),
  124. 3 then: 3 else: 0 "Job listings" => (defined?(::JobListing) ? safe_count(::JobListing) : "—")
  125. }
  126. 3 { status: :healthy, metrics: metrics }
  127. rescue StandardError
  128. { status: :unknown, metrics: {} }
  129. end
  130. 1 def scraping_health
  131. 3 else: 3 then: 0 return { status: :unknown, metrics: {} } unless defined?(::ScrapingAttempt)
  132. 3 recent_attempts = ::ScrapingAttempt.where("created_at > ?", 24.hours.ago)
  133. 3 total = recent_attempts.count
  134. 3 successful = recent_attempts.where(status: :completed).count
  135. 3 failed = recent_attempts.where(status: :failed).count
  136. 3 stuck = recent_attempts.where(status: :processing).where("updated_at < ?", 1.hour.ago).count
  137. 3 then: 0 else: 3 success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
  138. status =
  139. 3 then: 0 if stuck > 5 || (total > 10 && success_rate < 50)
  140. else: 3 :critical
  141. 3 then: 0 elsif stuck > 0 || (total > 10 && success_rate < 80)
  142. :degraded
  143. else: 3 else
  144. 3 :healthy
  145. end
  146. {
  147. 3 status: status,
  148. metrics: {
  149. "24h attempts" => total,
  150. "success rate" => "#{success_rate}%",
  151. "failed" => failed,
  152. "stuck" => stuck
  153. }
  154. }
  155. rescue StandardError
  156. { status: :unknown, metrics: {} }
  157. end
  158. 1 def llm_health
  159. 3 else: 3 then: 0 return { status: :unknown, metrics: {} } unless defined?(::Ai::LlmApiLog)
  160. 3 recent_logs = ::Ai::LlmApiLog.where("created_at > ?", 24.hours.ago)
  161. 3 total = recent_logs.count
  162. 3 successful = recent_logs.where(status: :success).count
  163. 3 failed = recent_logs.where(status: :failed).count
  164. 3 then: 0 else: 3 avg_latency = recent_logs.where(status: :success).average(:latency_ms)&.round || 0
  165. 3 total_cost_cents = recent_logs.sum(:estimated_cost_cents) || 0
  166. 3 total_cost = (total_cost_cents / 100.0).round(2)
  167. 3 then: 0 else: 3 success_rate = total > 0 ? (successful.to_f / total * 100).round : 0
  168. status =
  169. 3 then: 0 if total > 10 && success_rate < 80
  170. else: 3 :critical
  171. 3 then: 0 elsif total > 10 && success_rate < 95
  172. :degraded
  173. else: 3 else
  174. 3 :healthy
  175. end
  176. {
  177. 3 status: status,
  178. metrics: {
  179. "24h calls" => total,
  180. "success rate" => "#{success_rate}%",
  181. "avg latency" => "#{avg_latency}ms",
  182. "24h cost" => "$#{total_cost}",
  183. "failed" => failed
  184. }
  185. }
  186. rescue StandardError
  187. { status: :unknown, metrics: {} }
  188. end
  189. 1 def assistant_health
  190. 3 else: 3 then: 0 return { status: :unknown, metrics: {} } unless defined?(::Assistant::ToolExecution)
  191. 3 then: 3 else: 0 recent_threads = (defined?(::Assistant::ChatThread) ? ::Assistant::ChatThread.where("created_at > ?", 24.hours.ago) : nil)
  192. 3 recent_executions = ::Assistant::ToolExecution.where("created_at > ?", 24.hours.ago)
  193. 3 total_executions = recent_executions.count
  194. 3 successful = recent_executions.where(status: :completed).count
  195. 3 failed = recent_executions.where(status: :failed).count
  196. 3 pending = ::Assistant::ToolExecution.where(status: :pending_approval).count
  197. 3 then: 0 else: 3 success_rate = total_executions > 0 ? (successful.to_f / total_executions * 100).round : 100
  198. status =
  199. 3 then: 0 if failed > 10
  200. else: 3 :critical
  201. 3 then: 0 elsif pending > 20 || (total_executions > 10 && success_rate < 70)
  202. :degraded
  203. else: 3 else
  204. 3 :healthy
  205. end
  206. {
  207. 3 status: status,
  208. metrics: {
  209. 3 then: 3 else: 0 "24h threads" => (recent_threads ? recent_threads.count : "—"),
  210. "24h tool runs" => total_executions,
  211. "success rate" => "#{success_rate}%",
  212. "pending" => pending
  213. }
  214. }
  215. rescue StandardError
  216. { status: :unknown, metrics: {} }
  217. end
  218. 1 def safe_count(klass, scope_proc = nil)
  219. 12 rel = klass.all
  220. 12 then: 3 else: 9 rel = scope_proc.call(rel) if scope_proc
  221. 12 rel.count
  222. rescue StandardError
  223. "—"
  224. end
  225. end
  226. end

gems/admin_suite/app/controllers/admin_suite/docs_controller.rb

85.88% lines covered

66.67% branches covered

85 relevant lines. 73 lines covered and 12 lines missed.
24 total branches, 16 branches covered and 8 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 class DocsController < ApplicationController
  4. 1 before_action :set_docs_root
  5. # GET /docs
  6. # GET /docs?path=relative/path.md
  7. 1 def index
  8. 2 @files = grouped_markdown_files
  9. 2 @selected_path = params[:path].presence
  10. 2 then: 0 if @selected_path.present?
  11. else: 2 load_doc_content(@selected_path)
  12. 2 then: 2 else: 0 elsif @files.values.flatten.any?
  13. 2 @selected_path = @files.values.flatten.first
  14. 2 load_doc_content(@selected_path)
  15. end
  16. end
  17. # GET /docs/*path
  18. 1 def show
  19. 2 relative_path = params[:path].to_s
  20. 2 then: 0 else: 2 if params[:format].present? && !relative_path.end_with?(".#{params[:format]}")
  21. relative_path = "#{relative_path}.#{params[:format]}"
  22. end
  23. # Even when the doc path ends with `.md`, we always render HTML.
  24. 2 request.format = :html
  25. 2 file_path = resolve_doc_path!(relative_path)
  26. 1 @files = grouped_markdown_files
  27. 1 @selected_path = relative_path
  28. 1 @title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
  29. 1 @raw_markdown = File.read(file_path)
  30. 1 rendered = markdown_renderer.new(@raw_markdown).render
  31. 1 @content_html = rendered[:html]
  32. 1 @toc = rendered[:toc]
  33. 1 @reading_time = rendered[:reading_time_minutes]
  34. 1 render :index, formats: [:html]
  35. rescue ActiveRecord::RecordNotFound
  36. 1 redirect_to docs_path, alert: "Doc not found."
  37. end
  38. 1 private
  39. 1 def set_docs_root
  40. 4 @docs_root = docs_root
  41. end
  42. 1 def load_doc_content(relative_path)
  43. 2 file_path = resolve_doc_path!(relative_path)
  44. 2 @title = File.basename(file_path, ".md").tr("_", " ").tr("-", " ").titleize
  45. 2 @raw_markdown = File.read(file_path)
  46. 2 rendered = markdown_renderer.new(@raw_markdown).render
  47. 2 @content_html = rendered[:html]
  48. 2 @toc = rendered[:toc]
  49. 2 @reading_time = rendered[:reading_time_minutes]
  50. rescue ActiveRecord::RecordNotFound
  51. @title = nil
  52. @content_html = nil
  53. @toc = []
  54. @reading_time = nil
  55. end
  56. 1 def markdown_renderer
  57. 3 AdminSuite::MarkdownRenderer
  58. rescue NameError
  59. # In development, new engine lib files can be added without a server restart.
  60. # Make the docs viewer resilient by loading the renderer on demand.
  61. require "admin_suite/markdown_renderer"
  62. AdminSuite::MarkdownRenderer
  63. end
  64. 1 def grouped_markdown_files
  65. 3 base = docs_root_realpath
  66. 3 files = Dir.glob(base.join("**/*.md")).sort.map do |abs|
  67. 120 abs_path = Pathname.new(abs)
  68. 120 abs_path.relative_path_from(base).to_s
  69. end
  70. 123 groups = files.group_by { |path| group_name_for_path(path) }
  71. 42 groups.sort_by { |k, _| k.to_s }.to_h
  72. rescue StandardError
  73. {}
  74. end
  75. 1 def group_name_for_path(relative_path)
  76. 120 folder = relative_path.to_s.split(File::SEPARATOR).first
  77. 120 then: 114 else: 6 if folder.present? && folder != File.basename(relative_path.to_s)
  78. 114 return humanize_folder_name(folder)
  79. end
  80. 6 "Docs"
  81. end
  82. 1 def humanize_folder_name(folder)
  83. 114 normalized = folder.to_s.tr("_", " ").tr("-", " ").strip
  84. 114 acronyms = {
  85. "cicd" => "CICD",
  86. "ci cd" => "CICD",
  87. "ai" => "AI",
  88. "ops" => "Ops",
  89. "oauth" => "OAuth",
  90. "ui" => "UI",
  91. "ux" => "UX",
  92. "api" => "API"
  93. }
  94. 114 key = normalized.downcase
  95. 114 then: 18 else: 96 return acronyms[key] if acronyms.key?(key)
  96. 96 normalized.titleize
  97. end
  98. 1 def docs_root
  99. value =
  100. 10 then: 10 if AdminSuite.config.respond_to?(:docs_path)
  101. 10 AdminSuite.config.docs_path
  102. else: 0 else
  103. Rails.root.join("docs")
  104. end
  105. 10 then: 3 else: 7 value = value.call(self) if value.respond_to?(:call)
  106. 10 then: 0 else: 10 value = Rails.root.join("docs") if value.blank?
  107. 10 Pathname.new(value.to_s)
  108. end
  109. 1 def resolve_doc_path!(relative_path)
  110. 4 then: 0 else: 4 raise ActiveRecord::RecordNotFound if relative_path.blank?
  111. 4 then: 1 else: 3 raise ActiveRecord::RecordNotFound if relative_path.include?("..")
  112. 3 base = docs_root_realpath
  113. 3 candidate = base.join(relative_path)
  114. 3 else: 3 then: 0 raise ActiveRecord::RecordNotFound unless candidate.extname == ".md"
  115. 3 real = candidate.realpath
  116. 3 else: 3 then: 0 raise ActiveRecord::RecordNotFound unless real.to_s.start_with?(base.to_s + File::SEPARATOR)
  117. 3 real.to_s
  118. rescue Errno::ENOENT, Errno::EACCES
  119. raise ActiveRecord::RecordNotFound
  120. end
  121. 1 def docs_root_realpath
  122. 6 docs_root.realpath
  123. rescue Errno::ENOENT
  124. docs_root
  125. end
  126. end
  127. end

gems/admin_suite/app/helpers/admin_suite/base_helper.rb

10.76% lines covered

0.51% branches covered

632 relevant lines. 68 lines covered and 564 lines missed.
392 total branches, 2 branches covered and 390 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. # Helper methods for the Admin Suite engine UI.
  4. #
  5. # This is intentionally very close to the `/internal/developer` helper so we can
  6. # keep both UIs side-by-side and compare behavior while migrating.
  7. 1 module BaseHelper
  8. 1 include Pagy::Frontend
  9. 1 include AdminSuite::IconHelper
  10. 1 include AdminSuite::PanelsHelper
  11. 1 include AdminSuite::ThemeHelper
  12. 1 then: 1 else: 0 include ::Internal::Developer::CustomRenderersHelper if defined?(::Internal::Developer::CustomRenderersHelper)
  13. # ActiveStorage route helpers live on the host app (main_app), not the isolated engine.
  14. 1 def admin_suite_rails_blob_path(...)
  15. then: 0 if respond_to?(:main_app) && main_app.respond_to?(:rails_blob_path)
  16. main_app.rails_blob_path(...)
  17. else: 0 else
  18. rails_blob_path(...)
  19. end
  20. end
  21. 1 def admin_suite_rails_blob_representation_path(...)
  22. then: 0 if respond_to?(:main_app) && main_app.respond_to?(:rails_blob_representation_path)
  23. main_app.rails_blob_representation_path(...)
  24. else: 0 else
  25. rails_blob_representation_path(...)
  26. end
  27. end
  28. # Lookup the DSL field definition for a given attribute (if present).
  29. #
  30. # Used to render show values with type awareness (e.g. markdown/json/label).
  31. 1 def admin_suite_field_definition(field_name)
  32. else: 0 then: 0 return nil unless respond_to?(:resource_config, true)
  33. rc = resource_config
  34. else: 0 then: 0 return nil unless rc
  35. then: 0 else: 0 rc.form_config&.fields_list.to_a.find do |f|
  36. f.respond_to?(:name) &&
  37. f.respond_to?(:type) &&
  38. f.name.to_sym == field_name.to_sym
  39. end
  40. rescue StandardError
  41. nil
  42. end
  43. # Prefer registry-driven implementations (with legacy fallbacks via `super`).
  44. 1 prepend AdminSuite::UI::ShowValueFormatter
  45. 1 prepend AdminSuite::UI::FormFieldRenderer
  46. # Returns the color scheme for a portal
  47. #
  48. # @param portal_key [Symbol] Portal identifier
  49. # @return [String]
  50. 1 def portal_color(portal_key)
  51. 60 portal_key = portal_key.to_sym
  52. 60 color = (navigation_items.dig(portal_key, :color) rescue nil)
  53. 60 then: 60 else: 0 return color.to_s if color.present?
  54. when: 0 case portal_key
  55. when: 0 when :ops then "amber"
  56. when: 0 when :ai then "cyan"
  57. when: 0 when :assistant then "violet"
  58. else: 0 when :email then "emerald"
  59. else "slate"
  60. end
  61. end
  62. # Returns an icon for a portal.
  63. #
  64. # @param portal_key [Symbol] Portal identifier
  65. # @return [ActiveSupport::SafeBuffer, String]
  66. 1 def portal_icon(portal_key, **opts)
  67. 60 portal_key = portal_key.to_sym
  68. 60 icon = (navigation_items.dig(portal_key, :icon) rescue nil)
  69. 60 icon ||= begin
  70. {
  71. ops: "settings",
  72. ai: "sparkles",
  73. assistant: "bot",
  74. email: "mail"
  75. }[portal_key]
  76. end
  77. 60 icon = icon.presence || "layout-grid"
  78. 60 admin_suite_icon(icon, **opts)
  79. end
  80. # Renders a column value from a record
  81. #
  82. # @param record [ActiveRecord::Base] The record
  83. # @param column [Admin::Base::Resource::ColumnDefinition] Column definition
  84. # @return [String]
  85. 1 def render_column_value(record, column)
  86. then: 0 if column.type == :toggle
  87. field = (column.toggle_field || column.name).to_sym
  88. render partial: "admin_suite/shared/toggle_cell",
  89. else: 0 locals: { record: record, field: field }
  90. then: 0 elsif column.type == :label
  91. then: 0 else: 0 value = column.content.is_a?(Proc) ? column.content.call(record) : (record.public_send(column.name) rescue nil)
  92. else: 0 render_label_badge(value, color: column.label_color, size: column.label_size, record: record)
  93. then: 0 elsif column.content.is_a?(Proc)
  94. column.content.call(record)
  95. else: 0 else
  96. record.public_send(column.name) rescue "—"
  97. end
  98. end
  99. # Formats a value for display on show pages
  100. #
  101. # @param record [ActiveRecord::Base] The record
  102. # @param field_name [Symbol, String] Field name
  103. # @return [String] HTML safe formatted value
  104. 1 def format_show_value(record, field_name)
  105. value = record.public_send(field_name) rescue nil
  106. then: 0 if value.is_a?(ActiveStorage::Attached::One)
  107. else: 0 return render_attachment_preview(value)
  108. then: 0 else: 0 elsif value.is_a?(ActiveStorage::Attached::Many)
  109. return render_attachments_preview(value)
  110. end
  111. case value
  112. when: 0 when nil
  113. content_tag(:span, "—", class: "text-slate-400")
  114. when: 0 when true
  115. content_tag(:span, class: "inline-flex items-center gap-1") do
  116. svg = '<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>'.html_safe
  117. concat(svg)
  118. concat(content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
  119. end
  120. when: 0 when false
  121. content_tag(:span, class: "inline-flex items-center gap-1") do
  122. svg = '<svg class="w-4 h-4 text-slate-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'.html_safe
  123. concat(svg)
  124. concat(content_tag(:span, "No", class: "text-slate-500"))
  125. end
  126. when: 0 when Time, DateTime
  127. content_tag(:span, class: "inline-flex items-center gap-2") do
  128. concat(content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
  129. concat(content_tag(:span, "(#{time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
  130. end
  131. when: 0 when Date
  132. value.strftime("%B %d, %Y")
  133. when: 0 when ActiveRecord::Base
  134. then: 0 else: 0 link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
  135. content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
  136. when: 0 when Hash
  137. render_json_block(value)
  138. when: 0 when Array
  139. then: 0 if value.empty?
  140. else: 0 content_tag(:span, "Empty array", class: "text-slate-400 italic")
  141. then: 0 elsif value.first.is_a?(Hash)
  142. render_json_block(value)
  143. else: 0 else
  144. content_tag(:div, class: "flex flex-wrap gap-1") do
  145. value.each do |item|
  146. concat(content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"))
  147. end
  148. end
  149. end
  150. when: 0 when Integer, Float, BigDecimal
  151. content_tag(:span, number_with_delimiter(value), class: "font-mono")
  152. else: 0 else
  153. value_str = value.to_s
  154. then: 0 if value_str.start_with?("{", "[") && value_str.length > 10
  155. begin
  156. parsed = JSON.parse(value_str)
  157. render_json_block(parsed)
  158. rescue JSON::ParserError
  159. render_text_block(value_str)
  160. else: 0 end
  161. then: 0 elsif value_str.include?("\n") || value_str.length > 200
  162. render_text_block(value_str, detect_language(field_name, value_str))
  163. else: 0 else
  164. value_str
  165. end
  166. end
  167. end
  168. 1 def render_attachment_preview(attachment)
  169. else: 0 then: 0 return content_tag(:span, "—", class: "text-slate-400") unless attachment.attached?
  170. blob = attachment.blob
  171. then: 0 if blob.image?
  172. variant = attachment.variant(resize_to_limit: [ 600, 400 ])
  173. variant_url =
  174. begin
  175. admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
  176. rescue StandardError
  177. admin_suite_rails_blob_path(blob, disposition: :inline)
  178. end
  179. content_tag(:div, class: "space-y-2") do
  180. concat(content_tag(:div, class: "inline-block rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
  181. image_tag(variant_url,
  182. class: "max-w-full h-auto max-h-64 object-contain",
  183. alt: blob.filename.to_s)
  184. end)
  185. concat(content_tag(:div, class: "flex items-center gap-3 text-sm text-slate-500 dark:text-slate-400") do
  186. concat(content_tag(:span, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300"))
  187. concat(content_tag(:span, "•"))
  188. concat(content_tag(:span, number_to_human_size(blob.byte_size)))
  189. concat(content_tag(:span, "•"))
  190. concat(link_to("View full size", admin_suite_rails_blob_path(blob, disposition: :inline), target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
  191. end)
  192. end
  193. else: 0 else
  194. content_tag(:div, class: "flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700") do
  195. concat(content_tag(:div, class: "flex-shrink-0 w-10 h-10 bg-slate-200 dark:bg-slate-700 rounded-lg flex items-center justify-center") do
  196. '<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
  197. end)
  198. concat(content_tag(:div, class: "flex-1 min-w-0") do
  199. concat(content_tag(:p, blob.filename.to_s, class: "font-medium text-slate-700 dark:text-slate-300 truncate"))
  200. concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-sm text-slate-500 dark:text-slate-400"))
  201. end)
  202. concat(link_to("Download", admin_suite_rails_blob_path(blob, disposition: :attachment),
  203. class: "flex-shrink-0 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors"))
  204. end
  205. end
  206. end
  207. 1 def render_attachments_preview(attachments)
  208. else: 0 then: 0 return content_tag(:span, "—", class: "text-slate-400") unless attachments.attached?
  209. content_tag(:div, class: "grid grid-cols-2 md:grid-cols-3 gap-4") do
  210. attachments.each do |attachment|
  211. concat(render_attachment_preview(attachment))
  212. end
  213. end
  214. end
  215. 1 def render_json_block(data)
  216. json_str = JSON.pretty_generate(data)
  217. content_tag(:div, class: "relative group") do
  218. concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
  219. concat(content_tag(:span, "JSON", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
  220. concat(content_tag(:button,
  221. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
  222. type: "button",
  223. class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
  224. data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": json_str },
  225. title: "Copy to clipboard"))
  226. end)
  227. concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto") do
  228. content_tag(:code, class: "language-json") do
  229. highlight_json(json_str)
  230. end
  231. end)
  232. end
  233. end
  234. 1 def render_text_block(text, language = nil)
  235. content_tag(:div, class: "relative group") do
  236. concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
  237. then: 0 else: 0 concat(content_tag(:span, language.to_s.upcase, class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider")) if language
  238. concat(content_tag(:button,
  239. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
  240. type: "button",
  241. class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
  242. data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": text },
  243. title: "Copy to clipboard"))
  244. end)
  245. concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-96 overflow-y-auto whitespace-pre-wrap") do
  246. then: 0 else: 0 content_tag(:code, h(text), class: language ? "language-#{language}" : nil)
  247. end)
  248. end
  249. end
  250. 1 def highlight_json(json_str)
  251. highlighted = h(json_str)
  252. .gsub(/("(?:[^"\\]|\\.)*")(\s*:)/) { "<span class=\"text-purple-400\">#{$1}</span>#{$2}" }
  253. .gsub(/:\s*("(?:[^"\\]|\\.)*")/) { ":<span class=\"text-green-400\">#{$1}</span>" }
  254. .gsub(/:\s*(true|false)/) { ":<span class=\"text-orange-400\">#{$1}</span>" }
  255. .gsub(/:\s*(-?\d+(?:\.\d+)?)/) { ":<span class=\"text-cyan-400\">#{$1}</span>" }
  256. .gsub(/:\s*(null)/) { ":<span class=\"text-red-400\">#{$1}</span>" }
  257. highlighted.html_safe
  258. end
  259. 1 def detect_language(field_name, content)
  260. field_str = field_name.to_s.downcase
  261. then: 0 else: 0 return :markdown if field_str.include?("template") || field_str.include?("prompt")
  262. then: 0 else: 0 return :ruby if field_str.include?("code") && content.include?("def ")
  263. then: 0 else: 0 return :sql if field_str.include?("query") || field_str.include?("sql")
  264. then: 0 else: 0 return :html if field_str.include?("html") || field_str.include?("body")
  265. then: 0 else: 0 return :json if content.strip.start_with?("{", "[")
  266. then: 0 else: 0 return :ruby if content.include?("def ") || content.include?("class ")
  267. then: 0 else: 0 return :sql if content.upcase.include?("SELECT ") || content.upcase.include?("INSERT ")
  268. then: 0 else: 0 return :html if content.include?("<html") || content.include?("<div")
  269. nil
  270. end
  271. 1 def render_custom_section(resource, render_type)
  272. renderer = AdminSuite.config.custom_renderers[render_type.to_sym] rescue nil
  273. then: 0 else: 0 return renderer.call(resource, self) if renderer
  274. case render_type.to_sym
  275. when: 0 when :prompt_template_preview
  276. render_prompt_template(resource)
  277. when: 0 when :json_preview
  278. render_json_preview(resource)
  279. when: 0 when :code_preview
  280. render_code_preview(resource)
  281. when: 0 when :messages_preview
  282. render_messages_preview(resource)
  283. when: 0 when :tool_args_preview
  284. render_tool_args_preview(resource)
  285. when: 0 when :turn_messages_preview
  286. render_turn_messages_preview(resource)
  287. else: 0 else
  288. content_tag(:p, "Unknown render type: #{render_type}", class: "text-slate-500 italic")
  289. end
  290. end
  291. # --- generic custom renderers (fallbacks) ---
  292. 1 def render_prompt_template(resource)
  293. then: 0 else: 0 template = resource.respond_to?(:prompt_template) ? resource.prompt_template : nil
  294. then: 0 else: 0 return content_tag(:p, "No template defined", class: "text-slate-500 italic") if template.blank?
  295. highlighted_template = h(template).gsub(/\{\{(\w+)\}\}/) do
  296. "<span class=\"text-amber-400 bg-amber-900/30 px-1 rounded\">{{#{$1}}}</span>"
  297. end
  298. content_tag(:div, class: "relative group") do
  299. concat(content_tag(:div, class: "absolute top-2 right-2 flex items-center gap-2") do
  300. concat(content_tag(:span, "TEMPLATE", class: "text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider"))
  301. concat(content_tag(:button,
  302. '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>'.html_safe,
  303. type: "button",
  304. class: "p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 opacity-0 group-hover:opacity-100 transition-opacity",
  305. data: { controller: "admin-suite--clipboard", action: "click->admin-suite--clipboard#copy", "admin-suite--clipboard-text-value": template },
  306. title: "Copy to clipboard"))
  307. end)
  308. concat(content_tag(:pre, class: "bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono max-h-[600px] overflow-y-auto whitespace-pre-wrap leading-relaxed") do
  309. highlighted_template.html_safe
  310. end)
  311. variables = template.scan(/\{\{(\w+)\}\}/).flatten.uniq
  312. then: 0 else: 0 if variables.any?
  313. concat(content_tag(:div, class: "mt-3 pt-3 border-t border-slate-700") do
  314. concat(content_tag(:span, "Variables: ", class: "text-sm text-slate-400"))
  315. concat(content_tag(:div, class: "inline-flex flex-wrap gap-1 mt-1") do
  316. variables.each do |var|
  317. concat(content_tag(:code, "{{#{var}}}", class: "text-xs px-2 py-0.5 bg-amber-900/30 text-amber-400 rounded"))
  318. end
  319. end)
  320. end)
  321. end
  322. end
  323. end
  324. 1 def render_json_preview(resource)
  325. then: 0 else: 0 data = resource.respond_to?(:data) ? resource.data : resource.attributes
  326. render_json_block(data)
  327. end
  328. 1 def render_code_preview(resource)
  329. then: 0 else: 0 code = resource.respond_to?(:code) ? resource.code : resource.to_s
  330. render_text_block(code, :ruby)
  331. end
  332. 1 def render_messages_preview(resource)
  333. then: 0 else: 0 messages = resource.respond_to?(:messages) ? resource.messages : []
  334. then: 0 else: 0 if messages.respond_to?(:chronological)
  335. messages = messages.chronological
  336. end
  337. then: 0 else: 0 messages = messages.limit(50) if messages.respond_to?(:limit)
  338. messages = Array.wrap(messages)
  339. then: 0 else: 0 return content_tag(:p, "No messages", class: "text-slate-500 italic") if messages.blank?
  340. content_tag(:div, class: "space-y-4 max-h-[600px] overflow-y-auto -mx-6 -mb-6 p-6 pt-0") do
  341. messages.each_with_index do |msg, idx|
  342. then: 0 if msg.respond_to?(:role)
  343. role = msg.role
  344. content = msg.content
  345. then: 0 else: 0 created_at = msg.respond_to?(:created_at) ? msg.created_at : nil
  346. else: 0 else
  347. role = msg["role"] || msg[:role] || "unknown"
  348. content = msg["content"] || msg[:content] || ""
  349. created_at = msg["created_at"] || msg[:created_at]
  350. end
  351. when: 0 role_class = case role.to_s
  352. when: 0 when "user" then "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
  353. when: 0 when "assistant" then "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800"
  354. when: 0 when "tool" then "bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800"
  355. else: 0 when "system" then "bg-slate-50 dark:bg-slate-700/50 border-slate-200 dark:border-slate-600"
  356. else "bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700"
  357. end
  358. role_icon = case role.to_s
  359. when: 0 when "user"
  360. '<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe
  361. when: 0 when "assistant"
  362. '<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe
  363. when: 0 when "tool"
  364. '<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg>'.html_safe
  365. else: 0 else
  366. '<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>'.html_safe
  367. end
  368. concat(content_tag(:div, class: "rounded-lg border p-4 #{role_class}") do
  369. concat(content_tag(:div, class: "flex items-center justify-between mb-3") do
  370. concat(content_tag(:div, class: "flex items-center gap-2") do
  371. concat(role_icon)
  372. concat(content_tag(:span, role.to_s.capitalize, class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
  373. end)
  374. concat(content_tag(:div, class: "flex items-center gap-2 text-xs text-slate-400") do
  375. then: 0 else: 0 concat(content_tag(:span, created_at.strftime("%H:%M:%S"))) if created_at.respond_to?(:strftime)
  376. concat(content_tag(:span, "##{idx + 1}"))
  377. end)
  378. end)
  379. content_str = content.to_s
  380. then: 0 if role.to_s == "tool" && content_str.start_with?("{", "[")
  381. begin
  382. parsed = JSON.parse(content_str)
  383. concat(render_json_block(parsed))
  384. rescue JSON::ParserError
  385. concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
  386. end
  387. else: 0 else
  388. concat(content_tag(:div, simple_format(h(content_str)), class: "prose dark:prose-invert prose-sm max-w-none"))
  389. end
  390. end)
  391. end
  392. end
  393. end
  394. 1 def render_tool_args_preview(resource)
  395. then: 0 else: 0 then: 0 else: 0 args = resource.respond_to?(:args) ? resource.args : (resource.respond_to?(:arguments) ? resource.arguments : {})
  396. then: 0 else: 0 result = resource.respond_to?(:result) ? resource.result : nil
  397. then: 0 else: 0 error = resource.respond_to?(:error) ? resource.error : nil
  398. content_tag(:div, class: "space-y-6") do
  399. concat(content_tag(:div) do
  400. concat(content_tag(:h4, "Arguments", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
  401. then: 0 if args.present? && args != {}
  402. concat(render_json_block(args))
  403. else: 0 else
  404. concat(content_tag(:p, "No arguments", class: "text-slate-400 italic text-sm"))
  405. end
  406. end)
  407. then: 0 else: 0 if result.present? && result != {}
  408. concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
  409. concat(content_tag(:h4, "Result", class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
  410. concat(render_json_block(result))
  411. end)
  412. end
  413. then: 0 else: 0 if error.present?
  414. concat(content_tag(:div, class: "pt-4 border-t border-slate-200 dark:border-slate-700") do
  415. concat(content_tag(:h4, "Error", class: "text-sm font-medium text-red-500 dark:text-red-400 mb-2"))
  416. concat(content_tag(:div, class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4") do
  417. content_tag(:pre, h(error.to_s), class: "text-sm text-red-700 dark:text-red-300 whitespace-pre-wrap font-mono")
  418. end)
  419. end)
  420. end
  421. end
  422. end
  423. 1 def render_turn_messages_preview(resource)
  424. then: 0 else: 0 user_msg = resource.respond_to?(:user_message) ? resource.user_message : nil
  425. then: 0 else: 0 asst_msg = resource.respond_to?(:assistant_message) ? resource.assistant_message : nil
  426. content_tag(:div, class: "space-y-4") do
  427. then: 0 else: 0 if user_msg
  428. concat(content_tag(:div, class: "rounded-lg border p-4 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800") do
  429. concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
  430. concat('<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'.html_safe)
  431. concat(content_tag(:span, "User", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
  432. end)
  433. then: 0 else: 0 concat(content_tag(:div, simple_format(h(user_msg.respond_to?(:content) ? user_msg.content.to_s : user_msg.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
  434. end)
  435. end
  436. then: 0 else: 0 if asst_msg
  437. concat(content_tag(:div, class: "rounded-lg border p-4 bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800") do
  438. concat(content_tag(:div, class: "flex items-center gap-2 mb-2") do
  439. concat('<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/></svg>'.html_safe)
  440. concat(content_tag(:span, "Assistant", class: "text-sm font-medium text-slate-700 dark:text-slate-200"))
  441. end)
  442. then: 0 else: 0 concat(content_tag(:div, simple_format(h(asst_msg.respond_to?(:content) ? asst_msg.content.to_s : asst_msg.to_s)), class: "prose dark:prose-invert prose-sm max-w-none"))
  443. end)
  444. end
  445. else: 0 then: 0 concat(content_tag(:p, "No messages found", class: "text-slate-400 italic text-sm")) unless user_msg || asst_msg
  446. end
  447. end
  448. 1 def auto_admin_suite_path_for(item)
  449. else: 0 then: 0 return nil unless item.is_a?(ActiveRecord::Base)
  450. ensure_admin_resources_loaded_for!(item.class)
  451. resource = Admin::Base::Resource.registered_resources.find { |r| r.model_class == item.class }
  452. then: 0 else: 0 else: 0 then: 0 return nil unless resource&.portal_name && resource.respond_to?(:resource_name_plural)
  453. resource_path(portal: resource.portal_name, resource_name: resource.resource_name_plural, id: item.to_param)
  454. rescue StandardError
  455. nil
  456. end
  457. 1 def ensure_admin_resources_loaded_for!(model_class)
  458. already_loaded = Admin::Base::Resource.registered_resources.any? { |r| r.model_class == model_class }
  459. then: 0 else: 0 return if already_loaded
  460. Array(AdminSuite.config.resource_globs).flat_map { |g| Dir[g] }.uniq.each do |file|
  461. require file
  462. end
  463. rescue NameError
  464. require "admin/base/resource"
  465. retry
  466. end
  467. # ---- show page sections / associations ----
  468. #
  469. # For parity, we keep the same section rendering and association displays used by
  470. # `/internal/developer`. This is intentionally "UI heavy".
  471. 1 def render_show_section(resource, section, position = :main)
  472. is_association = section.association.present? && !resource.public_send(section.association).is_a?(ActiveRecord::Base) rescue false
  473. content_tag(:div, class: "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden") do
  474. then: 0 else: 0 header_padding = position == :sidebar ? "px-4 py-2.5" : "px-6 py-3"
  475. then: 0 else: 0 header_text_size = position == :sidebar ? "text-sm" : ""
  476. then: 0 else: 0 header_border = is_association ? "" : "border-b border-slate-200 dark:border-slate-700"
  477. concat(content_tag(:div, class: "#{header_padding} #{header_border} bg-slate-50 dark:bg-slate-900/50 flex items-center justify-between") do
  478. concat(content_tag(:h3, section.title, class: "font-medium text-slate-900 dark:text-white #{header_text_size}"))
  479. then: 0 else: 0 if section.association.present?
  480. assoc = resource.public_send(section.association) rescue nil
  481. then: 0 else: 0 if assoc && !assoc.is_a?(ActiveRecord::Base)
  482. count = assoc.count rescue 0
  483. then: 0 else: 0 color_class = count > 0 ? "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400" : "bg-slate-200 dark:bg-slate-600 text-slate-600 dark:text-slate-300"
  484. concat(content_tag(:span, number_with_delimiter(count), class: "text-xs font-semibold px-2 py-0.5 rounded-full #{color_class}"))
  485. end
  486. end
  487. end)
  488. then: 0 else: 0 content_padding = position == :sidebar ? "p-4" : "p-6"
  489. then: 0 else: 0 if is_association && position == :main
  490. then: 0 else: 0 content_padding = section.paginate ? "pt-0 px-6 pb-0" : "pt-0 px-6 pb-6"
  491. end
  492. then: 0 else: 0 content_padding = "pt-0 p-4" if is_association && position == :sidebar
  493. concat(content_tag(:div, class: content_padding) do
  494. then: 0 if section.render.present?
  495. else: 0 render_custom_section(resource, section.render)
  496. then: 0 elsif section.association.present?
  497. else: 0 render_association_section(resource, section)
  498. then: 0 elsif section.fields.any?
  499. then: 0 else: 0 position == :sidebar ? render_sidebar_fields(resource, section.fields) : render_main_fields(resource, section.fields)
  500. else: 0 else
  501. content_tag(:p, "No content", class: "text-slate-400 italic text-sm")
  502. end
  503. end)
  504. end
  505. end
  506. 1 def render_sidebar_fields(resource, fields)
  507. content_tag(:div, class: "space-y-3") do
  508. fields.each do |field_name|
  509. value = resource.public_send(field_name) rescue nil
  510. then: 0 if value.is_a?(ActiveStorage::Attached::One) || value.is_a?(ActiveStorage::Attached::Many)
  511. concat(render_sidebar_attachment(value))
  512. else: 0 else
  513. concat(content_tag(:div, class: "flex justify-between items-start gap-2") do
  514. concat(content_tag(:span, field_name.to_s.humanize, class: "text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider flex-shrink-0"))
  515. concat(content_tag(:span, class: "text-sm text-slate-900 dark:text-white text-right") { format_show_value(resource, field_name) })
  516. end)
  517. end
  518. end
  519. end
  520. end
  521. 1 def render_sidebar_attachment(attachment)
  522. else: 0 then: 0 return content_tag(:div, class: "text-center py-4") { content_tag(:span, "No image", class: "text-slate-400 text-sm") } unless attachment.respond_to?(:attached?) && attachment.attached?
  523. then: 0 else: 0 single = attachment.is_a?(ActiveStorage::Attached::Many) ? attachment.first : attachment
  524. blob = single.blob
  525. then: 0 if blob.image?
  526. variant = single.variant(resize_to_limit: [ 400, 300 ])
  527. variant_url =
  528. begin
  529. admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
  530. rescue StandardError
  531. admin_suite_rails_blob_path(blob, disposition: :inline)
  532. end
  533. content_tag(:div, class: "space-y-2") do
  534. concat(content_tag(:div, class: "rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700") do
  535. image_tag(variant_url, class: "w-full h-auto object-cover", alt: blob.filename.to_s)
  536. end)
  537. concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-500 dark:text-slate-400") do
  538. concat(content_tag(:span, number_to_human_size(blob.byte_size)))
  539. concat(link_to("View full", admin_suite_rails_blob_path(blob, disposition: :inline), target: "_blank", class: "text-indigo-600 dark:text-indigo-400 hover:underline"))
  540. end)
  541. end
  542. else: 0 else
  543. content_tag(:div, class: "flex items-center gap-2 p-2 bg-slate-50 dark:bg-slate-800 rounded-lg") do
  544. concat(content_tag(:div, class: "flex-shrink-0 w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded flex items-center justify-center") do
  545. '<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>'.html_safe
  546. end)
  547. concat(content_tag(:div, class: "flex-1 min-w-0") do
  548. concat(content_tag(:p, blob.filename.to_s.truncate(20), class: "text-xs font-medium text-slate-700 dark:text-slate-300 truncate"))
  549. concat(content_tag(:p, number_to_human_size(blob.byte_size), class: "text-xs text-slate-500"))
  550. end)
  551. end
  552. end
  553. end
  554. 1 def render_main_fields(resource, fields)
  555. content_tag(:dl, class: "space-y-6") do
  556. fields.each do |field_name|
  557. concat(content_tag(:div) do
  558. concat(content_tag(:dt, field_name.to_s.humanize, class: "text-sm font-medium text-slate-500 dark:text-slate-400 mb-2"))
  559. concat(content_tag(:dd, class: "text-sm text-slate-900 dark:text-white") { format_show_value(resource, field_name) })
  560. end)
  561. end
  562. end
  563. end
  564. # ---- association rendering ----
  565. 1 def render_association_section(resource, section)
  566. associated = resource.public_send(section.association) rescue nil
  567. then: 0 else: 0 return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if associated.nil?
  568. is_single = !associated.respond_to?(:to_a) || associated.is_a?(ActiveRecord::Base)
  569. then: 0 else: 0 return render_association_card_single(associated, section) if is_single
  570. items = associated
  571. pagy = nil
  572. then: 0 if section.paginate
  573. per_page = (section.per_page || section.limit || 20).to_i
  574. then: 0 else: 0 per_page = 1 if per_page < 1
  575. page_param = association_page_param(section)
  576. page = params[page_param].presence || 1
  577. then: 0 else: 0 total_count = associated.respond_to?(:count) ? associated.count : associated.to_a.size
  578. pagy = Pagy.new(count: total_count, page: page, limit: per_page, page_param: page_param)
  579. else: 0 then: 0 else: 0 items = associated.respond_to?(:offset) ? associated.offset(pagy.offset).limit(per_page) : Array.wrap(associated)[pagy.offset, per_page] || []
  580. then: 0 else: 0 elsif section.limit
  581. then: 0 else: 0 items = associated.respond_to?(:limit) ? associated.limit(section.limit) : Array.wrap(associated).first(section.limit)
  582. end
  583. items = Array.wrap(items)
  584. then: 0 else: 0 return content_tag(:p, "None found", class: "text-slate-400 italic text-sm") if items.empty?
  585. content_tag(:div) do
  586. case section.display
  587. when: 0 when :table
  588. concat(render_association_table(items, section))
  589. when: 0 when :cards
  590. concat(render_association_cards(items, section))
  591. else: 0 else
  592. concat(render_association_list(items, section))
  593. end
  594. then: 0 else: 0 concat(render_association_pagination(pagy)) if pagy
  595. end
  596. end
  597. 1 def association_page_param(section) = "#{section.association}_page"
  598. 1 def render_association_pagination(pagy)
  599. content_tag(:div, class: "-mx-6 border-t border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-900/30 px-6 py-3") do
  600. content_tag(:nav, class: "flex items-center justify-between", "aria-label" => "Pagination") do
  601. concat(pagy_prev_link(pagy))
  602. concat(pagy_page_links(pagy))
  603. concat(pagy_next_link(pagy))
  604. end
  605. end
  606. end
  607. 1 def pagy_prev_link(pagy)
  608. then: 0 if pagy.prev
  609. link_to("Prev", pagy_url_for(pagy, pagy.prev),
  610. class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
  611. else: 0 else
  612. content_tag(:span, "Prev",
  613. class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
  614. end
  615. end
  616. 1 def pagy_next_link(pagy)
  617. then: 0 if pagy.next
  618. link_to("Next", pagy_url_for(pagy, pagy.next),
  619. class: "px-3 py-1.5 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
  620. else: 0 else
  621. content_tag(:span, "Next",
  622. class: "px-3 py-1.5 text-sm font-medium text-slate-400 dark:text-slate-500 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg cursor-not-allowed")
  623. end
  624. end
  625. 1 def pagy_page_links(pagy)
  626. content_tag(:div, class: "flex items-center gap-1") do
  627. pagy.series.each { |item| concat(render_pagy_series_item(pagy, item)) }
  628. end
  629. end
  630. 1 def render_pagy_series_item(pagy, item)
  631. case item
  632. when: 0 when Integer
  633. link_to(item, pagy_url_for(pagy, item),
  634. class: "px-2.5 py-1 text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors")
  635. when: 0 when String
  636. content_tag(:span, item, class: "px-2.5 py-1 text-sm font-semibold text-white bg-indigo-600 border border-indigo-600 rounded")
  637. when: 0 when :gap
  638. content_tag(:span, "…", class: "px-2 text-sm text-slate-400 dark:text-slate-500")
  639. else: 0 else
  640. ""
  641. end
  642. end
  643. 1 def render_association_card_single(item, section)
  644. link_path = build_association_link(item, section)
  645. card_content = capture do
  646. concat(content_tag(:div, class: "flex items-center justify-between gap-3") do
  647. concat(content_tag(:div, class: "min-w-0 flex-1") do
  648. title = item_display_title(item)
  649. then: 0 else: 0 title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
  650. concat(content_tag(:div, title, class: title_class))
  651. subtitle = []
  652. then: 0 else: 0 subtitle << item.status.to_s.humanize if item.respond_to?(:status) && item.status.present?
  653. then: 0 else: 0 subtitle << item.email_address if item.respond_to?(:email_address) && item.email_address.present?
  654. then: 0 else: 0 subtitle << item.tool_key if item.respond_to?(:tool_key) && item.tool_key.present?
  655. then: 0 else: 0 concat(content_tag(:div, subtitle.first, class: "text-sm text-slate-500 dark:text-slate-400 mt-0.5")) if subtitle.any?
  656. end)
  657. then: 0 else: 0 if link_path
  658. concat('<svg class="w-5 h-5 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
  659. end
  660. end)
  661. end
  662. then: 0 else: 0 link_path ? link_to(card_content, link_path, class: "flex items-center -m-4 p-4 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/10 transition-colors group") : content_tag(:div, card_content, class: "flex items-center")
  663. end
  664. 1 def render_association_list(items, section)
  665. content_tag(:div, class: "divide-y divide-slate-200 dark:divide-slate-700 -mx-6 -mt-2 -mb-6") do
  666. items.each do |item|
  667. link_path = build_association_link(item, section)
  668. then: 0 wrapper = if link_path
  669. ->(content) { link_to(link_path, class: "block px-6 py-4 hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 transition-colors group") { content } }
  670. else: 0 else
  671. ->(content) { content_tag(:div, content, class: "px-6 py-4") }
  672. end
  673. concat(wrapper.call(capture do
  674. concat(content_tag(:div, class: "flex items-start justify-between gap-4") do
  675. concat(content_tag(:div, class: "min-w-0 flex-1") do
  676. concat(content_tag(:div, class: "flex items-center gap-2") do
  677. title = item_display_title(item)
  678. then: 0 else: 0 title_class = link_path ? "text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "text-slate-900 dark:text-white"
  679. concat(content_tag(:span, title.truncate(60), class: "font-medium #{title_class} truncate"))
  680. then: 0 else: 0 concat(render_status_badge(item.status, size: :sm)) if item.respond_to?(:status) && item.status.present?
  681. end)
  682. end)
  683. concat(content_tag(:div, class: "flex items-center gap-3 flex-shrink-0 text-xs text-slate-400") do
  684. then: 0 else: 0 concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago")) if item.respond_to?(:created_at) && item.created_at
  685. then: 0 else: 0 if link_path
  686. concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe)
  687. end
  688. end)
  689. end)
  690. end))
  691. end
  692. end
  693. end
  694. # Minimal association table support (matches internal portal table UX enough for now).
  695. 1 def render_association_table(items, section)
  696. columns = section.columns.presence || detect_table_columns(items.first)
  697. content_tag(:div, class: "overflow-x-auto -mx-6 -mt-1") do
  698. content_tag(:table, class: "min-w-full divide-y divide-slate-200 dark:divide-slate-700") do
  699. concat(content_tag(:thead, class: "bg-slate-50/50 dark:bg-slate-900/30") do
  700. content_tag(:tr) do
  701. Array.wrap(columns).each do |col|
  702. header = col.to_s.gsub(/_id$/, "").humanize
  703. concat(content_tag(:th, header, class: "px-4 py-2.5 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider first:pl-6"))
  704. end
  705. concat(content_tag(:th, "", class: "px-4 py-2.5 w-16"))
  706. end
  707. end)
  708. concat(content_tag(:tbody, class: "divide-y divide-slate-200 dark:divide-slate-700") do
  709. items.each do |item|
  710. link_path = build_association_link(item, section)
  711. then: 0 else: 0 concat(content_tag(:tr, class: link_path ? "hover:bg-indigo-50/50 dark:hover:bg-indigo-900/10 cursor-pointer group" : "") do
  712. Array.wrap(columns).each_with_index do |col, idx|
  713. value = item.public_send(col) rescue nil
  714. text = format_table_cell(value)
  715. then: 0 else: 0 concat(content_tag(:td, text, class: (idx == 0 ? "px-4 py-3 text-sm first:pl-6" : "px-4 py-3 text-sm")))
  716. end
  717. concat(content_tag(:td, class: "px-4 py-3 text-right pr-6") do
  718. then: 0 else: 0 link_path ? link_to("View", link_path, class: "inline-flex items-center text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 text-sm font-medium") : ""
  719. end)
  720. end)
  721. end
  722. end)
  723. end
  724. end
  725. end
  726. 1 def render_association_cards(items, section)
  727. content_tag(:div, class: "grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1") do
  728. items.each do |item|
  729. link_path = build_association_link(item, section)
  730. card_class = "border border-slate-200 dark:border-slate-700 rounded-lg p-4 transition-all"
  731. then: 0 else: 0 card_class += link_path ? " hover:border-indigo-300 dark:hover:border-indigo-700 hover:shadow-md group cursor-pointer" : " hover:bg-slate-50 dark:hover:bg-slate-900/30"
  732. card_content = capture do
  733. concat(content_tag(:div, class: "flex items-start justify-between gap-2 mb-2") do
  734. title = item_display_title(item)
  735. then: 0 else: 0 title_class = link_path ? "font-medium text-slate-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400" : "font-medium text-slate-900 dark:text-white"
  736. concat(content_tag(:span, title.truncate(35), class: title_class))
  737. then: 0 else: 0 concat(render_status_badge(item.status, size: :sm)) if item.respond_to?(:status) && item.status.present?
  738. end)
  739. concat(content_tag(:div, class: "flex items-center justify-between text-xs text-slate-400 pt-2 border-t border-slate-100 dark:border-slate-700/50") do
  740. then: 0 else: 0 concat(content_tag(:span, time_ago_in_words(item.created_at) + " ago")) if item.respond_to?(:created_at) && item.created_at
  741. then: 0 else: 0 concat('<svg class="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-500 group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>'.html_safe) if link_path
  742. end)
  743. end
  744. then: 0 else: 0 concat(link_path ? link_to(card_content, link_path, class: card_class) : content_tag(:div, card_content, class: card_class))
  745. end
  746. end
  747. end
  748. 1 def detect_table_columns(item)
  749. else: 0 then: 0 return [ :id, :name, :created_at ] unless item
  750. priority = [ :name, :title, :status ]
  751. attrs = item.attributes.keys.map(&:to_sym)
  752. selected = priority.select { |c| attrs.include?(c) }
  753. then: 0 else: 0 selected << :created_at if selected.size < 5 && attrs.include?(:created_at)
  754. selected.take(5)
  755. end
  756. 1 def format_table_cell(value)
  757. when: 0 case value
  758. when: 0 when nil then "—"
  759. when: 0 then: 0 else: 0 when true, false then value ? "Yes" : "No"
  760. when: 0 when Time, DateTime then value.strftime("%b %d, %H:%M")
  761. when: 0 when Date then value.strftime("%b %d, %Y")
  762. else: 0 when ActiveRecord::Base then item_display_title(value)
  763. else value.to_s.truncate(50)
  764. end
  765. end
  766. 1 def item_display_title(item)
  767. then: 0 else: 0 return item.name if item.respond_to?(:name) && item.name.present?
  768. then: 0 else: 0 return item.title if item.respond_to?(:title) && item.title.present?
  769. then: 0 else: 0 return item.display_title if item.respond_to?(:display_title) && item.display_title.present?
  770. then: 0 else: 0 return item.content.to_s.truncate(50) if item.respond_to?(:content)
  771. "##{item.id}"
  772. end
  773. 1 def build_association_link(item, section)
  774. then: 0 else: 0 if section.link_to.present?
  775. begin
  776. return send(section.link_to, item)
  777. rescue NoMethodError
  778. # fall through to auto-link
  779. end
  780. end
  781. auto_admin_suite_path_for(item)
  782. end
  783. 1 def render_status_badge(status, size: :md)
  784. then: 0 else: 0 return content_tag(:span, "—", class: "text-slate-400") if status.blank?
  785. status_str = status.to_s.downcase
  786. colors = case status_str
  787. when: 0 when "active", "open", "success", "approved", "completed", "enabled"
  788. "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
  789. when: 0 when "pending", "proposed", "queued", "waiting"
  790. "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
  791. when: 0 when "running", "processing", "in_progress"
  792. "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
  793. when: 0 when "error", "failed", "rejected", "cancelled"
  794. "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
  795. else: 0 else
  796. "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
  797. end
  798. then: 0 else: 0 padding = size == :sm ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
  799. content_tag(:span, status_str.titleize, class: "inline-flex items-center #{padding} rounded-full font-medium #{colors}")
  800. end
  801. 1 def render_label_badge(value, color: nil, size: :md, record: nil)
  802. then: 0 else: 0 return content_tag(:span, "—", class: "text-slate-400") if value.blank?
  803. label_color = resolve_label_option(color, record).presence || :slate
  804. label_size = resolve_label_option(size, record).presence || :md
  805. colors = label_badge_colors(label_color)
  806. then: 0 else: 0 padding = label_size.to_s == "sm" ? "px-1.5 py-0.5 text-xs" : "px-2 py-1 text-xs"
  807. content_tag(:span, value.to_s, class: "inline-flex items-center #{padding} rounded-md font-medium #{colors}")
  808. end
  809. 1 def resolve_label_option(option, record)
  810. then: 0 else: 0 return option.call(record) if option.is_a?(Proc)
  811. option
  812. end
  813. 1 def label_badge_colors(color)
  814. case color.to_s.downcase
  815. when: 0 when "green"
  816. "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
  817. when: 0 when "amber", "yellow", "orange"
  818. "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
  819. when: 0 when "blue"
  820. "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400"
  821. when: 0 when "red"
  822. "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"
  823. when: 0 when "indigo"
  824. "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400"
  825. when: 0 when "purple"
  826. "bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400"
  827. when: 0 when "violet"
  828. "bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-400"
  829. when: 0 when "emerald"
  830. "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400"
  831. when: 0 when "cyan"
  832. "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400"
  833. else: 0 else
  834. "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-400"
  835. end
  836. end
  837. # ---- form fields ----
  838. 1 def render_form_field(f, field, resource)
  839. then: 0 else: 0 return if field.if_condition.present? && !field.if_condition.call(resource)
  840. then: 0 else: 0 return if field.unless_condition.present? && field.unless_condition.call(resource)
  841. capture do
  842. concat(content_tag(:div, class: "form-group") do
  843. concat(f.label(field.name, class: "form-label") do
  844. concat(field.label)
  845. then: 0 else: 0 concat(content_tag(:span, " *", class: "text-red-500")) if field.required
  846. end)
  847. field_class = "form-input w-full"
  848. then: 0 else: 0 field_class += " border-red-500" if resource.errors[field.name].any?
  849. when: 0 field_html = case field.type
  850. when: 0 when :textarea then f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
  851. when: 0 when :url then f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  852. when: 0 when :email then f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  853. when: 0 when :number then f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  854. when :toggle then render_toggle_field(f, field, resource)
  855. when: 0 when :label
  856. label_value = resource.public_send(field.name) rescue nil
  857. render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
  858. when: 0 when :select
  859. then: 0 else: 0 collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
  860. when: 0 f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
  861. when: 0 when :searchable_select then render_searchable_select(f, field, resource)
  862. when: 0 when :multi_select, :tags then render_multi_select(f, field, resource)
  863. when: 0 when :image, :attachment then render_file_upload(f, field, resource)
  864. when :trix, :rich_text then f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
  865. when: 0 when :markdown
  866. when: 0 f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
  867. when: 0 when :file then f.file_field(field.name, class: "form-input-file", accept: field.accept)
  868. when: 0 when :datetime then f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
  869. when: 0 when :date then f.date_field(field.name, class: field_class, readonly: field.readonly)
  870. when :time then f.time_field(field.name, class: field_class, readonly: field.readonly)
  871. when: 0 when :json
  872. when: 0 render("admin_suite/shared/json_editor_field", f: f, field: field, resource: resource)
  873. when :code then render_code_editor(f, field, resource)
  874. else: 0 else
  875. f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  876. end
  877. concat(field_html)
  878. then: 0 else: 0 concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400")) if field.help.present?
  879. then: 0 else: 0 concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600 dark:text-red-400")) if resource.errors[field.name].any?
  880. end)
  881. end
  882. end
  883. 1 def render_toggle_field(_f, field, resource)
  884. checked = !!resource.public_send(field.name)
  885. param_key = resource.class.model_name.param_key
  886. content_tag(:div,
  887. class: "inline-flex items-center gap-3",
  888. data: {
  889. controller: "admin-suite--toggle-switch",
  890. "admin-suite--toggle-switch-active-class-value": "is-on",
  891. "admin-suite--toggle-switch-inactive-classes-value": ""
  892. }) do
  893. concat(content_tag(:button, type: "button",
  894. then: 0 else: 0 class: "admin-suite-toggle-track #{checked ? "is-on" : ""}",
  895. role: "switch",
  896. "aria-checked" => checked.to_s,
  897. data: { action: "click->admin-suite--toggle-switch#toggle", "admin-suite--toggle-switch-target": "button" },
  898. disabled: field.readonly) do
  899. content_tag(:span, "", class: "admin-suite-toggle-thumb", data: { "admin-suite--toggle-switch-target": "thumb" })
  900. end)
  901. then: 0 else: 0 concat(hidden_field_tag("#{param_key}[#{field.name}]", checked ? "1" : "0", id: "#{param_key}_#{field.name}", data: { "admin-suite--toggle-switch-target": "input" }))
  902. then: 0 else: 0 concat(content_tag(:span, checked ? "Enabled" : "Disabled", class: "text-sm font-medium text-slate-700", data: { "admin-suite--toggle-switch-target": "label" }))
  903. end
  904. end
  905. 1 def render_searchable_select(_f, field, resource)
  906. param_key = resource.class.model_name.param_key
  907. current_value = resource.public_send(field.name)
  908. then: 0 else: 0 collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
  909. then: 0 options_json = if collection.is_a?(Array)
  910. then: 0 else: 0 collection.map { |opt| opt.is_a?(Array) ? { value: opt[1], label: opt[0] } : { value: opt, label: opt.to_s.humanize } }.to_json
  911. else: 0 else
  912. "[]"
  913. end
  914. then: 0 current_label = if current_value.present? && collection.is_a?(Array)
  915. then: 0 else: 0 match = collection.find { |opt| opt.is_a?(Array) ? opt[1].to_s == current_value.to_s : opt.to_s == current_value.to_s }
  916. then: 0 else: 0 match.is_a?(Array) ? match[0] : match.to_s
  917. else: 0 else
  918. current_value
  919. end
  920. content_tag(:div,
  921. data: {
  922. controller: "admin-suite--searchable-select",
  923. "admin-suite--searchable-select-options-value": options_json,
  924. "admin-suite--searchable-select-creatable-value": field.create_url.present?,
  925. then: 0 else: 0 "admin-suite--searchable-select-search-url-value": collection.is_a?(String) ? collection : ""
  926. },
  927. class: "relative") do
  928. concat(hidden_field_tag("#{param_key}[#{field.name}]", current_value, data: { "admin-suite--searchable-select-target": "input" }))
  929. concat(text_field_tag(nil, current_label,
  930. class: "form-input w-full",
  931. placeholder: field.placeholder || "Search...",
  932. autocomplete: "off",
  933. data: {
  934. "admin-suite--searchable-select-target": "search",
  935. action: "input->admin-suite--searchable-select#search focus->admin-suite--searchable-select#open keydown->admin-suite--searchable-select#keydown"
  936. }))
  937. concat(content_tag(:div, "",
  938. class: "absolute z-10 w-full mt-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg hidden max-h-60 overflow-y-auto",
  939. data: { "admin-suite--searchable-select-target": "dropdown" }))
  940. end
  941. end
  942. 1 def render_multi_select(_f, field, resource)
  943. param_key = resource.class.model_name.param_key
  944. current_values =
  945. then: 0 if resource.respond_to?("#{field.name}_list")
  946. else: 0 resource.public_send("#{field.name}_list")
  947. then: 0 elsif resource.respond_to?(field.name)
  948. Array.wrap(resource.public_send(field.name))
  949. else: 0 else
  950. []
  951. end
  952. options =
  953. then: 0 if field.collection.is_a?(Proc)
  954. else: 0 field.collection.call
  955. then: 0 elsif field.collection.is_a?(Array)
  956. field.collection
  957. else: 0 else
  958. []
  959. end
  960. then: 0 else: 0 field_name = field.type == :tags ? "tag_list" : field.name
  961. full_field_name = "#{param_key}[#{field_name}][]"
  962. content_tag(:div,
  963. data: {
  964. controller: "admin-suite--tag-select",
  965. "admin-suite--tag-select-creatable-value": field.create_url.present? || field.type == :tags,
  966. "admin-suite--tag-select-field-name-value": full_field_name
  967. },
  968. class: "space-y-2") do
  969. concat(hidden_field_tag(full_field_name, "", id: nil, data: { "admin-suite--tag-select-target": "placeholder" }))
  970. concat(content_tag(:div,
  971. class: "flex flex-wrap gap-2 min-h-[2.5rem] p-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg",
  972. data: { "admin-suite--tag-select-target": "tags" }) do
  973. current_values.each do |val|
  974. concat(content_tag(:span,
  975. class: "inline-flex items-center gap-1 px-2 py-1 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 rounded text-sm") do
  976. concat(val.to_s)
  977. concat(hidden_field_tag(full_field_name, val, id: nil))
  978. concat(button_tag("×", type: "button", class: "text-indigo-500 hover:text-indigo-700 font-bold", data: { action: "admin-suite--tag-select#remove" }))
  979. end)
  980. end
  981. concat(text_field_tag(nil, "",
  982. class: "flex-1 min-w-[120px] border-none focus:outline-none focus:ring-0 bg-transparent text-sm",
  983. placeholder: field.placeholder || "Add tag...",
  984. autocomplete: "off",
  985. data: { "admin-suite--tag-select-target": "input", action: "keydown->admin-suite--tag-select#keydown input->admin-suite--tag-select#search" }))
  986. end)
  987. then: 0 else: 0 if options.any?
  988. concat(content_tag(:div,
  989. class: "hidden border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 shadow-lg max-h-48 overflow-y-auto",
  990. data: { "admin-suite--tag-select-target": "dropdown" }) do
  991. options.each do |opt|
  992. then: 0 else: 0 label, value = opt.is_a?(Array) ? [ opt[0], opt[1] ] : [ opt, opt ]
  993. concat(content_tag(:button, label,
  994. type: "button",
  995. class: "block w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-700",
  996. data: { action: "admin-suite--tag-select#select", value: value }))
  997. end
  998. end)
  999. end
  1000. end
  1001. end
  1002. 1 def render_file_upload(f, field, resource)
  1003. then: 0 else: 0 attachment = resource.respond_to?(field.name) ? resource.public_send(field.name) : nil
  1004. has_attachment = attachment.respond_to?(:attached?) && attachment.attached?
  1005. is_image = field.type == :image || (field.accept.present? && field.accept.include?("image"))
  1006. existing_url =
  1007. then: 0 else: 0 if has_attachment && is_image
  1008. variant = attachment.variant(resize_to_limit: [ 300, 300 ])
  1009. begin
  1010. admin_suite_rails_blob_representation_path(variant.processed, only_path: true)
  1011. rescue StandardError
  1012. admin_suite_rails_blob_path(attachment.blob, disposition: :inline)
  1013. end
  1014. end
  1015. content_tag(:div,
  1016. data: {
  1017. controller: "admin-suite--file-upload",
  1018. then: 0 else: 0 "admin-suite--file-upload-accept-value": field.accept || (is_image ? "image/*" : "*/*"),
  1019. "admin-suite--file-upload-preview-value": field.type == :image,
  1020. "admin-suite--file-upload-existing-url-value": existing_url
  1021. },
  1022. class: "space-y-3") do
  1023. then: 0 if has_attachment && is_image
  1024. concat(content_tag(:div, class: "relative inline-block") do
  1025. concat(image_tag(existing_url, class: "max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover", data: { "admin-suite--file-upload-target": "imagePreview" }))
  1026. concat(button_tag("×", type: "button",
  1027. class: "absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center text-sm",
  1028. data: { "admin-suite--file-upload-target": "removeButton", action: "admin-suite--file-upload#remove" }))
  1029. end)
  1030. else: 0 else
  1031. concat(image_tag("", class: "hidden max-w-[200px] max-h-[150px] rounded-lg border border-slate-200 dark:border-slate-700 object-cover", data: { "admin-suite--file-upload-target": "imagePreview" }))
  1032. concat(content_tag(:div, "", class: "hidden", data: { "admin-suite--file-upload-target": "filename" }))
  1033. end
  1034. concat(content_tag(:div,
  1035. class: "relative border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg hover:border-indigo-400 dark:hover:border-indigo-500 transition-colors",
  1036. data: { "admin-suite--file-upload-target": "dropzone" }) do
  1037. concat(f.file_field(field.name,
  1038. class: "sr-only",
  1039. id: "#{field.name}_input",
  1040. then: 0 else: 0 accept: field.accept || (is_image ? "image/*" : nil),
  1041. data: { "admin-suite--file-upload-target": "input", action: "change->admin-suite--file-upload#preview" }))
  1042. concat(content_tag(:label, for: "#{field.name}_input",
  1043. class: "flex flex-col items-center justify-center w-full py-6 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-900/50 rounded-lg transition-colors") do
  1044. concat('<svg class="w-8 h-8 text-slate-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>'.html_safe)
  1045. concat(content_tag(:span, "Click to upload or drag and drop", class: "text-sm text-slate-500 dark:text-slate-400"))
  1046. then: 0 else: 0 concat(content_tag(:span, "PNG, JPG, WebP up to 10MB", class: "text-xs text-slate-400 mt-1")) if is_image
  1047. end)
  1048. end)
  1049. end
  1050. end
  1051. 1 def render_code_editor(f, field, _resource)
  1052. content_tag(:div, class: "relative", data: { controller: "admin-suite--code-editor" }) do
  1053. f.text_area(field.name,
  1054. class: "w-full font-mono text-sm bg-slate-900 text-slate-100 p-4 rounded-lg border border-slate-700 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500",
  1055. rows: field.rows || 12,
  1056. placeholder: field.placeholder,
  1057. data: { "admin-suite--code-editor-target": "textarea" })
  1058. end
  1059. end
  1060. end
  1061. end

gems/admin_suite/app/helpers/admin_suite/icon_helper.rb

85.0% lines covered

50.0% branches covered

20 relevant lines. 17 lines covered and 3 lines missed.
12 total branches, 6 branches covered and 6 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 module IconHelper
  4. # Renders an icon for AdminSuite using the configured renderer.
  5. #
  6. # Default behavior uses lucide-rails (LucideRails::IconProvider) if available.
  7. # Back-compat: if `name` looks like raw SVG markup, it is returned as HTML safe.
  8. #
  9. # @param name [String, Symbol] icon name (e.g. "settings") OR raw svg string
  10. # @param opts [Hash] passed to the underlying renderer (e.g. class:, stroke_width:)
  11. # @return [ActiveSupport::SafeBuffer, String]
  12. 1 def admin_suite_icon(name, **opts)
  13. 114 then: 0 else: 114 return "".html_safe if name.blank?
  14. 114 raw = name.to_s
  15. 114 then: 0 else: 114 if raw.lstrip.start_with?("<svg")
  16. return raw.html_safe
  17. end
  18. 114 renderer = AdminSuite.config.icon_renderer
  19. 114 then: 0 else: 114 return renderer.call(raw, self, **opts) if renderer.respond_to?(:call)
  20. # lucide-rails provides stripped SVG paths via IconProvider; we wrap them.
  21. 114 then: 114 else: 0 if defined?(::LucideRails::IconProvider)
  22. 114 default_class = "w-4 h-4"
  23. 114 css_class = [ default_class, opts[:class] ].compact.join(" ")
  24. 114 stroke_width = opts.fetch(:stroke_width, 2)
  25. 114 title = opts[:title]
  26. begin
  27. 114 inner = ::LucideRails::IconProvider.icon(raw)
  28. rescue ArgumentError
  29. inner = nil
  30. end
  31. 114 then: 114 else: 0 if inner.present?
  32. 114 return content_tag(
  33. :svg,
  34. 114 then: 0 else: 114 (title.present? ? content_tag(:title, title) + inner.html_safe : inner.html_safe),
  35. class: css_class,
  36. xmlns: "http://www.w3.org/2000/svg",
  37. width: "24",
  38. height: "24",
  39. viewBox: "0 0 24 24",
  40. fill: "none",
  41. stroke: "currentColor",
  42. "stroke-width" => stroke_width,
  43. "stroke-linecap" => "round",
  44. "stroke-linejoin" => "round",
  45. "aria-hidden" => "true",
  46. focusable: "false"
  47. )
  48. end
  49. end
  50. # Safety fallback if lucide-rails isn't available in the host app for any reason.
  51. content_tag(:span, "", class: opts[:class] || "inline-block w-4 h-4", title: raw)
  52. end
  53. end
  54. end

gems/admin_suite/app/helpers/admin_suite/panels_helper.rb

100.0% lines covered

70.0% branches covered

22 relevant lines. 22 lines covered and 0 lines missed.
10 total branches, 7 branches covered and 3 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 module PanelsHelper
  4. # Renders a portal dashboard rows grid.
  5. #
  6. # @param rows [Array<AdminSuite::UI::RowDefinition>]
  7. 1 def render_dashboard_rows(rows)
  8. 12 then: 0 else: 12 return "" if rows.blank?
  9. 12 content_tag(:div, class: "space-y-6") do
  10. 12 rows.each do |row|
  11. 12 concat(content_tag(:div, class: "grid grid-cols-1 lg:grid-cols-12 gap-6") do
  12. 12 Array(row.panels).each do |panel|
  13. 42 concat(render_panel(panel))
  14. end
  15. end)
  16. end
  17. end
  18. end
  19. # Renders a single panel by selecting a partial.
  20. #
  21. # Host apps can override by setting `AdminSuite.config.partials[:panel_<type>]`.
  22. #
  23. # @param panel [AdminSuite::UI::PanelDefinition]
  24. 1 def render_panel(panel)
  25. 42 type = panel.type.to_sym
  26. 42 override = AdminSuite.config.partials[:"panel_#{type}"] rescue nil
  27. 42 partial = override.presence || "admin_suite/panels/#{type}"
  28. 42 span = (panel.options[:span] || 12).to_i
  29. 42 then: 0 else: 42 span = 12 if span < 1
  30. 42 then: 0 else: 42 span = 12 if span > 12
  31. # Avoid dynamic Tailwind class generation (e.g. `lg:col-span-#{span}`),
  32. # which would otherwise require a safelist.
  33. 42 content_tag(:div, style: "grid-column: span #{span} / span #{span};") do
  34. 42 render partial:, locals: { panel: panel }
  35. end
  36. end
  37. # Evaluates a panel option, calling Procs if needed.
  38. #
  39. # @param value [Object, Proc]
  40. # @return [Object]
  41. 1 def panel_eval(value)
  42. 66 then: 12 else: 54 return value.call if value.is_a?(Proc) && value.arity == 0
  43. 54 then: 12 else: 42 return value.call(self) if value.is_a?(Proc) && value.arity == 1
  44. 42 value
  45. end
  46. end
  47. end

gems/admin_suite/app/helpers/admin_suite/theme_helper.rb

73.81% lines covered

50.0% branches covered

42 relevant lines. 31 lines covered and 11 lines missed.
10 total branches, 5 branches covered and 5 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 module ThemeHelper
  4. 1 def admin_suite_theme
  5. 6 (AdminSuite.config.theme || {}).symbolize_keys
  6. rescue StandardError
  7. {}
  8. end
  9. 1 def theme_primary
  10. admin_suite_theme[:primary]
  11. end
  12. 1 def theme_secondary
  13. admin_suite_theme[:secondary]
  14. end
  15. # Returns a <style> tag that scopes theme variables to AdminSuite.
  16. #
  17. # This is the core of engine-build mode theming: UI classes stay static
  18. # (no `bg-#{...}`), and color changes are driven by CSS variables.
  19. 1 def admin_suite_theme_style_tag
  20. 6 theme = admin_suite_theme
  21. 6 primary = theme[:primary]
  22. 6 secondary = theme[:secondary]
  23. primary_name =
  24. 6 then: 0 if AdminSuite::ThemePalette.hex?(primary)
  25. nil
  26. else: 6 else
  27. 6 AdminSuite::ThemePalette.normalize_color(primary, default_name: :indigo)
  28. end
  29. secondary_name =
  30. 6 then: 0 if AdminSuite::ThemePalette.hex?(secondary)
  31. nil
  32. else: 6 else
  33. 6 AdminSuite::ThemePalette.normalize_color(secondary, default_name: :purple)
  34. end
  35. # Primary variables
  36. 6 then: 0 else: 6 primary_600 = AdminSuite::ThemePalette.hex?(primary) ? primary : AdminSuite::ThemePalette.resolve(primary_name, 600, fallback: "#4f46e5")
  37. 6 then: 0 else: 6 primary_700 = AdminSuite::ThemePalette.hex?(primary) ? primary : AdminSuite::ThemePalette.resolve(primary_name, 700, fallback: "#4338ca")
  38. # Sidebar gradient variables (dark shades)
  39. 6 sidebar_from = AdminSuite::ThemePalette.resolve(primary_name || "indigo", 900, fallback: "#312e81")
  40. 6 sidebar_via = AdminSuite::ThemePalette.resolve(primary_name || "indigo", 800, fallback: "#3730a3")
  41. sidebar_to =
  42. 6 then: 0 if AdminSuite::ThemePalette.hex?(secondary)
  43. secondary
  44. else: 6 else
  45. 6 AdminSuite::ThemePalette.resolve(secondary_name || "purple", 900, fallback: "#581c87")
  46. end
  47. 6 css = <<~CSS
  48. body.admin-suite {
  49. --admin-suite-primary: #{primary_600};
  50. --admin-suite-primary-hover: #{primary_700};
  51. --admin-suite-sidebar-from: #{sidebar_from};
  52. --admin-suite-sidebar-via: #{sidebar_via};
  53. --admin-suite-sidebar-to: #{sidebar_to};
  54. }
  55. CSS
  56. 6 content_tag(:style, css.html_safe)
  57. end
  58. 1 def theme_link_class
  59. 12 "admin-suite-link"
  60. end
  61. 1 def theme_link_hover_text_class
  62. "admin-suite-link-hover"
  63. end
  64. 1 def theme_btn_primary_class
  65. "admin-suite-btn-primary"
  66. end
  67. 1 def theme_btn_primary_small_class
  68. "admin-suite-btn-primary admin-suite-btn-primary--sm"
  69. end
  70. 1 def theme_badge_primary_class
  71. 6 "admin-suite-badge-primary"
  72. end
  73. 1 def theme_focus_ring_class
  74. "admin-suite-focus-ring"
  75. end
  76. 1 def theme_sidebar_gradient_class
  77. # Deprecated: gradient is now CSS-variable driven (see `admin_suite_theme_style_tag`).
  78. ""
  79. end
  80. end
  81. end

gems/admin_suite/config/routes.rb

100.0% lines covered

100.0% branches covered

17 relevant lines. 17 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 AdminSuite::Engine.routes.draw do
  3. 1 root to: "dashboard#index"
  4. # Docs viewer (host filesystem-backed). Must be defined before `:portal` route.
  5. 1 get "docs(/)", to: "docs#index", as: :docs
  6. 1 get "docs/*path", to: "docs#show", as: :doc, format: false
  7. # Portal dashboards (e.g. /ops, /email). Accept optional trailing slash.
  8. 1 get ":portal(/)", to: "portals#show", as: :portal
  9. # Generic resource routes (dynamic)
  10. 1 scope ":portal/:resource_name" do
  11. 1 get "/", to: "resources#index", as: :resources
  12. 1 get "/new", to: "resources#new", as: :new_resource
  13. 1 post "/", to: "resources#create"
  14. 1 get "/:id", to: "resources#show", as: :resource
  15. 1 get "/:id/edit", to: "resources#edit", as: :edit_resource
  16. 1 patch "/:id", to: "resources#update"
  17. 1 put "/:id", to: "resources#update"
  18. 1 delete "/:id", to: "resources#destroy"
  19. 1 post "/:id/execute_action/:action_name", to: "resources#execute_action", as: :execute_action
  20. 1 post "/bulk_action/:action_name", to: "resources#bulk_action", as: :bulk_action
  21. end
  22. 1 post ":portal/:resource_name/:id/toggle", to: "resources#toggle", as: :resource_toggle
  23. end

gems/admin_suite/lib/admin/base/action_executor.rb

30.68% lines covered

0.0% branches covered

88 relevant lines. 27 lines covered and 61 lines missed.
40 total branches, 0 branches covered and 40 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Admin
  3. 1 module Base
  4. 1 class ActionExecutor
  5. 1 attr_reader :resource_class, :action_name, :actor
  6. 1 alias_method :current_user, :actor
  7. 1 Result = Struct.new(:success, :message, :redirect_url, :errors, keyword_init: true) do
  8. 1 def success? = success
  9. 1 def failure? = !success
  10. end
  11. 1 def initialize(resource_class, action_name, actor)
  12. @resource_class = resource_class
  13. @action_name = action_name
  14. @actor = actor
  15. end
  16. 1 def execute_member(record, params = {})
  17. action = find_member_action
  18. else: 0 then: 0 return failure_result("Action not found") unless action
  19. else: 0 then: 0 return failure_result("Condition not met") unless condition_met?(action, record)
  20. execute_action(action, record, params)
  21. end
  22. 1 def execute_bulk(records, params = {})
  23. action = find_bulk_action
  24. else: 0 then: 0 return failure_result("Action not found") unless action
  25. results = records.map { |record| execute_action(action, record, params) }
  26. success_count = results.count(&:success?)
  27. failure_count = results.count(&:failure?)
  28. then: 0 if failure_count.zero?
  29. else: 0 success_result("Successfully processed #{success_count} records")
  30. then: 0 elsif success_count.zero?
  31. failure_result("Failed to process all #{failure_count} records")
  32. else: 0 else
  33. success_result("Processed #{success_count} records, #{failure_count} failed")
  34. end
  35. end
  36. 1 def execute_collection(scope, params = {})
  37. action = find_collection_action
  38. else: 0 then: 0 return failure_result("Action not found") unless action
  39. execute_action(action, scope, params)
  40. end
  41. 1 def action_definition
  42. find_member_action || find_bulk_action || find_collection_action
  43. end
  44. 1 private
  45. 1 def actions_config = @resource_class.actions_config
  46. 1 def find_member_action
  47. else: 0 then: 0 return nil unless actions_config
  48. actions_config.member_actions.find { |a| a.name == action_name }
  49. end
  50. 1 def find_bulk_action
  51. else: 0 then: 0 return nil unless actions_config
  52. actions_config.bulk_actions.find { |a| a.name == action_name }
  53. end
  54. 1 def find_collection_action
  55. else: 0 then: 0 return nil unless actions_config
  56. actions_config.collection_actions.find { |a| a.name == action_name }
  57. end
  58. 1 def condition_met?(action, record)
  59. then: 0 else: 0 return evaluate_condition(action.if_condition, record) if action.if_condition.present?
  60. then: 0 else: 0 return !evaluate_condition(action.unless_condition, record) if action.unless_condition.present?
  61. true
  62. end
  63. 1 def evaluate_condition(condition_proc, record)
  64. then: 0 else: 0 condition_proc.arity.zero? ? record.instance_exec(&condition_proc) : condition_proc.call(record)
  65. end
  66. 1 def execute_action(action, target, params)
  67. result =
  68. then: 0 if target.respond_to?(action.name)
  69. else: 0 execute_model_method(target, action)
  70. then: 0 elsif target.respond_to?("#{action.name}!")
  71. execute_model_method(target, action, bang: true)
  72. else: 0 else
  73. handler_class = find_handler_class(action)
  74. then: 0 else: 0 handler_class ? execute_handler(handler_class, target, params) : failure_result("No handler found for action: #{action.name}")
  75. end
  76. notify_action_executed(action, target, params, result)
  77. result
  78. rescue StandardError => e
  79. result = failure_result("Error: #{e.message}")
  80. notify_action_executed(action, target, params, result)
  81. result
  82. end
  83. 1 def execute_model_method(record, action, bang: false)
  84. then: 0 else: 0 method_name = bang ? "#{action.name}!" : action.name
  85. record.public_send(method_name)
  86. success_result("#{action.label} completed successfully")
  87. rescue ActiveRecord::RecordInvalid => e
  88. failure_result("Validation failed: #{e.record.errors.full_messages.join(', ')}")
  89. rescue AASM::InvalidTransition => e
  90. failure_result("Invalid state transition: #{e.message}")
  91. end
  92. 1 def find_handler_class(action)
  93. then: 0 else: 0 if defined?(AdminSuite) && AdminSuite.config.resolve_action_handler.present?
  94. resolved = AdminSuite.config.resolve_action_handler.call(resource_class, action.name)
  95. then: 0 else: 0 return resolved if resolved
  96. end
  97. handler_name = "#{resource_class.resource_name.camelize}#{action.name.to_s.camelize}Action"
  98. "Admin::Actions::#{handler_name}".constantize
  99. rescue NameError
  100. nil
  101. end
  102. 1 def execute_handler(handler_class, target, params)
  103. handler_class.new(target, actor, params).call
  104. end
  105. 1 def success_result(message, redirect_url: nil)
  106. Result.new(success: true, message: message, redirect_url: redirect_url, errors: [])
  107. end
  108. 1 def failure_result(message, errors: [])
  109. Result.new(success: false, message: message, redirect_url: nil, errors: errors)
  110. end
  111. 1 def notify_action_executed(action, target, params, result)
  112. else: 0 then: 0 return unless defined?(AdminSuite)
  113. hook = AdminSuite.config.on_action_executed
  114. else: 0 then: 0 return unless hook
  115. hook.call(
  116. actor: actor,
  117. action_name: action.name,
  118. resource_class: resource_class,
  119. subject: target,
  120. params: params,
  121. result: result
  122. )
  123. rescue StandardError
  124. nil
  125. end
  126. end
  127. end
  128. end

gems/admin_suite/lib/admin/base/action_handler.rb

62.5% lines covered

100.0% branches covered

16 relevant lines. 10 lines covered and 6 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Admin
  3. 1 module Base
  4. 1 class ActionHandler
  5. 1 attr_reader :record, :actor, :params
  6. 1 alias_method :current_user, :actor
  7. 1 def initialize(record, actor, params = {})
  8. @record = record
  9. @actor = actor
  10. @params = params
  11. end
  12. 1 def call
  13. raise NotImplementedError, "Subclasses must implement #call"
  14. end
  15. 1 protected
  16. 1 def success(message, redirect_url: nil)
  17. Admin::Base::ActionExecutor::Result.new(success: true, message: message, redirect_url: redirect_url, errors: [])
  18. end
  19. 1 def failure(message, errors: [])
  20. Admin::Base::ActionExecutor::Result.new(success: false, message: message, redirect_url: nil, errors: errors)
  21. end
  22. end
  23. end
  24. end

gems/admin_suite/lib/admin/base/filter_builder.rb

20.29% lines covered

0.0% branches covered

69 relevant lines. 14 lines covered and 55 lines missed.
46 total branches, 0 branches covered and 46 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Admin
  3. 1 module Base
  4. 1 class FilterBuilder
  5. 1 attr_reader :resource_class, :params
  6. 1 def initialize(resource_class, params)
  7. @resource_class = resource_class
  8. @params = params
  9. end
  10. 1 def apply(scope)
  11. scope = apply_search(scope)
  12. scope = apply_filters(scope)
  13. scope = apply_sort(scope)
  14. scope
  15. end
  16. 1 def filter_params
  17. else: 0 then: 0 return {} unless index_config
  18. permitted_keys = [ :search, :sort, :sort_direction, :page ]
  19. permitted_keys += index_config.filters_list.map(&:name)
  20. params.permit(*permitted_keys).to_h.symbolize_keys
  21. end
  22. 1 private
  23. 1 def index_config
  24. @resource_class.index_config
  25. end
  26. 1 def apply_search(scope)
  27. else: 0 then: 0 return scope unless index_config
  28. then: 0 else: 0 return scope if params[:search].blank?
  29. then: 0 else: 0 return scope if index_config.searchable_fields.empty?
  30. then: 0 else: 0 return scope if params[:search].to_s.length < 3
  31. search_term = "%#{params[:search]}%"
  32. conditions = index_config.searchable_fields.map { |field| "#{field} ILIKE :search" }.join(" OR ")
  33. scope.where(conditions, search: search_term)
  34. end
  35. 1 def apply_filters(scope)
  36. else: 0 then: 0 return scope unless index_config
  37. index_config.filters_list.each do |filter|
  38. scope = apply_filter(scope, filter)
  39. end
  40. scope
  41. end
  42. 1 def apply_filter(scope, filter)
  43. # Some "filters" in the UI are really just controls (e.g. sort dropdown).
  44. # They are handled elsewhere (`apply_sort`) and must not be turned into SQL.
  45. then: 0 else: 0 return scope if %i[sort sort_direction direction page search].include?(filter.name.to_sym)
  46. value = params[filter.name]
  47. then: 0 else: 0 return scope if value.blank?
  48. then: 0 else: 0 if filter.respond_to?(:apply) && filter.apply.present?
  49. return apply_custom_filter(scope, filter.apply, value)
  50. end
  51. case filter.type
  52. when: 0 when :text, :search
  53. scope.where("#{filter.field} ILIKE ?", "%#{value}%")
  54. when: 0 when :select
  55. scope.where(filter.field => value)
  56. when: 0 when :toggle, :boolean
  57. bool_value = ActiveModel::Type::Boolean.new.cast(value)
  58. scope.where(filter.field => bool_value)
  59. when: 0 when :number
  60. scope.where(filter.field => value.to_i)
  61. when: 0 when :date
  62. date = Date.parse(value) rescue nil
  63. else: 0 then: 0 return scope unless date
  64. scope.where(filter.field => date.all_day)
  65. when: 0 when :date_range
  66. from_date = params["#{filter.name}_from"].presence
  67. to_date = params["#{filter.name}_to"].presence
  68. then: 0 else: 0 scope = scope.where("#{filter.field} >= ?", Date.parse(from_date)) if from_date.present?
  69. then: 0 else: 0 scope = scope.where("#{filter.field} <= ?", Date.parse(to_date).end_of_day) if to_date.present?
  70. scope
  71. when: 0 when :association
  72. scope.where("#{filter.field}_id" => value)
  73. else: 0 else
  74. scope
  75. end
  76. end
  77. 1 def apply_custom_filter(scope, filter_proc, value)
  78. then: 0 else: 0 filter_proc.arity == 2 ? filter_proc.call(scope, value) : filter_proc.call(scope, value, params)
  79. end
  80. 1 def apply_sort(scope)
  81. else: 0 then: 0 return scope unless index_config
  82. sort_field = params[:sort].presence || index_config.default_sort
  83. else: 0 then: 0 return scope unless sort_field
  84. else: 0 then: 0 unless index_config.sortable_fields.include?(sort_field.to_sym)
  85. sort_field = index_config.default_sort
  86. end
  87. else: 0 then: 0 return scope unless sort_field
  88. direction_param = params[:sort_direction].presence || params[:direction].presence
  89. direction =
  90. then: 0 if direction_param.present?
  91. then: 0 else: 0 direction_param.to_sym == :desc ? :desc : :asc
  92. else: 0 else
  93. (index_config.default_sort_direction || :desc).to_sym
  94. end
  95. scope.order(sort_field => direction)
  96. end
  97. end
  98. end
  99. end

gems/admin_suite/lib/admin/base/resource.rb

48.59% lines covered

0.0% branches covered

177 relevant lines. 86 lines covered and 91 lines missed.
54 total branches, 0 branches covered and 54 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module Admin
  3. 1 module Base
  4. # Base class for admin resource definitions
  5. #
  6. # Provides a declarative DSL for defining admin resources with:
  7. # - Index configuration (columns, filters, search, sort, stats)
  8. # - Form configuration (fields with various types)
  9. # - Actions (single and bulk)
  10. # - Show page sections
  11. # - Export capabilities
  12. #
  13. # @example
  14. # class Admin::Resources::CompanyResource < Admin::Base::Resource
  15. # model Company
  16. # portal :ops
  17. # section :content
  18. #
  19. # index do
  20. # searchable :name, :website
  21. # sortable :name, :created_at, default: :name
  22. #
  23. # columns do
  24. # column :name
  25. # column :job_listings, -> (c) { c.job_listings.count }
  26. # end
  27. #
  28. # filters do
  29. # filter :search, type: :text
  30. # filter :status, type: :select, options: %w[active inactive]
  31. # end
  32. # end
  33. #
  34. # form do
  35. # field :name, required: true
  36. # field :website, type: :url
  37. # field :is_active, type: :toggle
  38. # end
  39. # end
  40. 1 class Resource
  41. 1 class << self
  42. # Model configuration
  43. 1 attr_reader :model_class, :portal_name, :section_name, :nav_label, :nav_icon, :nav_order
  44. # Index configuration
  45. 1 attr_reader :index_config
  46. # Form configuration
  47. 1 attr_reader :form_config
  48. # Show configuration
  49. 1 attr_reader :show_config
  50. # Actions configuration
  51. 1 attr_reader :actions_config
  52. # Export configuration
  53. 1 attr_reader :export_formats
  54. # Sets the model class for this resource
  55. #
  56. # @param klass [Class] The ActiveRecord model class
  57. # @return [void]
  58. 1 def model(klass)
  59. @model_class = klass
  60. end
  61. # Sets the portal this resource belongs to
  62. #
  63. # @param name [Symbol] Portal name (:ops or :ai)
  64. # @return [void]
  65. 1 def portal(name)
  66. @portal_name = name
  67. end
  68. # Sets the section within the portal
  69. #
  70. # @param name [Symbol] Section name
  71. # @return [void]
  72. 1 def section(name)
  73. @section_name = name
  74. end
  75. # Navigation metadata for this resource.
  76. #
  77. # @param label [String, nil] override label used in nav
  78. # @param icon [String, Symbol, nil] lucide icon name (or raw svg string)
  79. # @param order [Integer, nil] sort order within section
  80. # @return [void]
  81. 1 def nav(label: nil, icon: nil, order: nil)
  82. then: 0 else: 0 @nav_label = label if label.present?
  83. then: 0 else: 0 @nav_icon = icon if icon.present?
  84. else: 0 then: 0 @nav_order = order unless order.nil?
  85. end
  86. # Convenience setter/getter for nav icon.
  87. #
  88. # @param name [String, Symbol, nil]
  89. # @return [String, Symbol, nil]
  90. 1 def icon(name = nil)
  91. then: 0 else: 0 @nav_icon = name if name.present?
  92. @nav_icon
  93. end
  94. # Convenience setter/getter for nav label.
  95. #
  96. # @param name [String, nil]
  97. # @return [String, nil]
  98. 1 def label(name = nil)
  99. then: 0 else: 0 @nav_label = name if name.present?
  100. @nav_label
  101. end
  102. # Convenience setter/getter for nav order.
  103. #
  104. # @param value [Integer, nil]
  105. # @return [Integer, nil]
  106. 1 def order(value = nil)
  107. else: 0 then: 0 @nav_order = value unless value.nil?
  108. @nav_order
  109. end
  110. # Configures the index view
  111. #
  112. # @yield Block for index configuration
  113. # @return [void]
  114. 1 def index(&block)
  115. @index_config = IndexConfig.new
  116. then: 0 else: 0 @index_config.instance_eval(&block) if block_given?
  117. end
  118. # Configures the form (new/edit)
  119. #
  120. # @yield Block for form configuration
  121. # @return [void]
  122. 1 def form(&block)
  123. @form_config = FormConfig.new
  124. then: 0 else: 0 @form_config.instance_eval(&block) if block_given?
  125. end
  126. # Configures the show view
  127. #
  128. # @yield Block for show configuration
  129. # @return [void]
  130. 1 def show(&block)
  131. @show_config = ShowConfig.new
  132. then: 0 else: 0 @show_config.instance_eval(&block) if block_given?
  133. end
  134. # Configures actions
  135. #
  136. # @yield Block for actions configuration
  137. # @return [void]
  138. 1 def actions(&block)
  139. @actions_config = ActionsConfig.new
  140. then: 0 else: 0 @actions_config.instance_eval(&block) if block_given?
  141. end
  142. # Configures export formats
  143. #
  144. # @param formats [Array<Symbol>] Export formats (:json, :csv)
  145. # @return [void]
  146. 1 def exportable(*formats)
  147. @export_formats = formats
  148. end
  149. # Returns the resource name derived from class name
  150. #
  151. # @return [String]
  152. 1 def resource_name
  153. name.demodulize.sub(/Resource$/, "").underscore
  154. end
  155. # Returns the plural resource name
  156. #
  157. # @return [String]
  158. 1 def resource_name_plural
  159. resource_name.pluralize
  160. end
  161. # Returns the human-readable name
  162. #
  163. # @return [String]
  164. 1 def human_name
  165. then: 0 else: 0 then: 0 else: 0 model_class&.model_name&.human || resource_name.humanize
  166. end
  167. # Returns the human-readable plural name
  168. #
  169. # @return [String]
  170. 1 def human_name_plural
  171. then: 0 else: 0 then: 0 else: 0 model_class&.model_name&.human(count: 2) || resource_name.pluralize.humanize
  172. end
  173. # Returns all registered resources
  174. #
  175. # @return [Array<Class>]
  176. 1 def registered_resources
  177. 150 @registered_resources ||= []
  178. end
  179. # Clears the registry (useful for development reloads).
  180. #
  181. # @return [void]
  182. 1 def reset_registry!
  183. @registered_resources = []
  184. end
  185. # Called when a subclass is created
  186. 1 def inherited(subclass)
  187. super
  188. then: 0 else: 0 then: 0 else: 0 return if subclass.name&.include?("Base")
  189. existing_idx = registered_resources.index { |r| r.name == subclass.name }
  190. then: 0 if existing_idx
  191. registered_resources[existing_idx] = subclass
  192. else: 0 else
  193. registered_resources << subclass
  194. end
  195. end
  196. # Returns resources for a specific portal
  197. #
  198. # @param portal [Symbol] Portal name
  199. # @return [Array<Class>]
  200. 1 def resources_for_portal(portal)
  201. 12 registered_resources.select { |r| r.portal_name == portal }
  202. end
  203. # Returns resources for a specific section
  204. #
  205. # @param portal [Symbol] Portal name
  206. # @param section [Symbol] Section name
  207. # @return [Array<Class>]
  208. 1 def resources_for_section(portal, section)
  209. registered_resources.select { |r| r.portal_name == portal && r.section_name == section }
  210. end
  211. end
  212. # Index view configuration
  213. 1 class IndexConfig
  214. 1 attr_reader :searchable_fields, :sortable_fields, :default_sort, :default_sort_direction,
  215. :columns_list, :filters_list, :stats_list, :per_page
  216. 1 def initialize
  217. @searchable_fields = []
  218. @sortable_fields = []
  219. @default_sort = nil
  220. @default_sort_direction = :desc
  221. @columns_list = []
  222. @filters_list = []
  223. @stats_list = []
  224. @per_page = 25
  225. end
  226. 1 def searchable(*fields)
  227. @searchable_fields = fields
  228. end
  229. 1 def sortable(*fields, default: nil, direction: :desc)
  230. then: 0 else: 0 @sortable_fields = fields if fields.any?
  231. @default_sort = default || fields.first
  232. @default_sort_direction = direction
  233. end
  234. 1 def paginate(count)
  235. @per_page = count
  236. end
  237. 1 def columns(&block)
  238. builder = ColumnsBuilder.new
  239. then: 0 else: 0 builder.instance_eval(&block) if block_given?
  240. @columns_list = builder.columns
  241. end
  242. 1 def filters(&block)
  243. builder = FiltersBuilder.new
  244. then: 0 else: 0 builder.instance_eval(&block) if block_given?
  245. @filters_list = builder.filters
  246. end
  247. 1 def stats(&block)
  248. builder = StatsBuilder.new
  249. then: 0 else: 0 builder.instance_eval(&block) if block_given?
  250. @stats_list = builder.stats
  251. end
  252. end
  253. 1 class ColumnsBuilder
  254. 1 attr_reader :columns
  255. 1 def initialize
  256. @columns = []
  257. end
  258. 1 def column(name, content = nil, **options)
  259. @columns << ColumnDefinition.new(
  260. name: name,
  261. content: content,
  262. render: options[:render],
  263. header: options[:header] || name.to_s.humanize,
  264. css_class: options[:class],
  265. type: options[:type],
  266. toggle_field: options[:toggle_field],
  267. label_color: options[:label_color],
  268. label_size: options[:label_size],
  269. sortable: options[:sortable] || false
  270. )
  271. end
  272. end
  273. 1 ColumnDefinition = Struct.new(:name, :content, :render, :header, :css_class, :type, :toggle_field, :label_color, :label_size, :sortable, keyword_init: true)
  274. 1 class FiltersBuilder
  275. 1 attr_reader :filters
  276. 1 def initialize
  277. @filters = []
  278. end
  279. 1 def filter(name, **options)
  280. then: 0 else: 0 select_options = options.key?(:options) ? options[:options] : options[:collection]
  281. @filters << FilterDefinition.new(
  282. name: name,
  283. type: options[:type] || :text,
  284. label: options[:label] || name.to_s.humanize,
  285. placeholder: options[:placeholder],
  286. options: select_options,
  287. field: options[:field] || name,
  288. apply: options[:apply]
  289. )
  290. end
  291. end
  292. 1 FilterDefinition = Struct.new(:name, :type, :label, :placeholder, :options, :field, :apply, keyword_init: true)
  293. 1 class StatsBuilder
  294. 1 attr_reader :stats
  295. 1 def initialize
  296. @stats = []
  297. end
  298. 1 def stat(name, calculator, **options)
  299. @stats << StatDefinition.new(
  300. name: name,
  301. calculator: calculator,
  302. color: options[:color]
  303. )
  304. end
  305. end
  306. 1 StatDefinition = Struct.new(:name, :calculator, :color, keyword_init: true)
  307. 1 class FormConfig
  308. 1 attr_reader :fields_list
  309. 1 def initialize
  310. @fields_list = []
  311. end
  312. 1 def field(name, **options)
  313. @fields_list << FieldDefinition.new(
  314. name: name,
  315. type: options[:type] || :text,
  316. required: options[:required] || false,
  317. label: options[:label] || name.to_s.humanize,
  318. help: options[:help],
  319. placeholder: options[:placeholder],
  320. collection: options[:collection],
  321. create_url: options[:create_url],
  322. accept: options[:accept],
  323. rows: options[:rows],
  324. readonly: options[:readonly] || false,
  325. if_condition: options[:if],
  326. unless_condition: options[:unless],
  327. multiple: options[:multiple] || false,
  328. creatable: options[:creatable] || false,
  329. preview: options[:preview] != false,
  330. variants: options[:variants],
  331. label_color: options[:label_color],
  332. label_size: options[:label_size]
  333. )
  334. end
  335. 1 def section(title, **options, &block)
  336. @fields_list << SectionDefinition.new(
  337. title: title,
  338. description: options[:description],
  339. collapsible: options[:collapsible] || false,
  340. collapsed: options[:collapsed] || false
  341. )
  342. then: 0 else: 0 instance_eval(&block) if block_given?
  343. @fields_list << SectionEnd.new
  344. end
  345. 1 def row(**options, &block)
  346. @fields_list << RowDefinition.new(cols: options[:cols] || 2)
  347. then: 0 else: 0 instance_eval(&block) if block_given?
  348. @fields_list << RowEnd.new
  349. end
  350. end
  351. 1 FieldDefinition = Struct.new(
  352. :name, :type, :required, :label, :help, :placeholder,
  353. :collection, :create_url, :accept, :rows, :readonly,
  354. :if_condition, :unless_condition, :multiple, :creatable,
  355. :preview, :variants, :label_color, :label_size,
  356. keyword_init: true
  357. )
  358. 1 SectionDefinition = Struct.new(:title, :description, :collapsible, :collapsed, keyword_init: true)
  359. 1 SectionEnd = Class.new
  360. 1 RowDefinition = Struct.new(:cols, keyword_init: true)
  361. 1 RowEnd = Class.new
  362. 1 class ShowConfig
  363. 1 attr_reader :sidebar_sections, :main_sections
  364. 1 def initialize
  365. @sidebar_sections = []
  366. @main_sections = []
  367. end
  368. 1 def section(name, **options)
  369. @main_sections << build_section(name, options)
  370. end
  371. 1 def sidebar(&block)
  372. @current_target = :sidebar
  373. then: 0 else: 0 instance_eval(&block) if block_given?
  374. @current_target = nil
  375. end
  376. 1 def main(&block)
  377. @current_target = :main
  378. then: 0 else: 0 instance_eval(&block) if block_given?
  379. @current_target = nil
  380. end
  381. 1 def panel(name, **options)
  382. section_def = build_section(name, options)
  383. case @current_target
  384. when: 0 when :sidebar
  385. @sidebar_sections << section_def
  386. else: 0 else
  387. @main_sections << section_def
  388. end
  389. end
  390. 1 def sections_list
  391. @main_sections
  392. end
  393. 1 private
  394. 1 def build_section(name, options)
  395. ShowSectionDefinition.new(
  396. name: name,
  397. fields: options[:fields] || [],
  398. association: options[:association],
  399. limit: options[:limit],
  400. render: options[:render],
  401. title: options[:title] || name.to_s.humanize,
  402. display: options[:display] || :list,
  403. columns: options[:columns] || [],
  404. link_to: options[:link_to],
  405. resource: options[:resource],
  406. paginate: options[:paginate] || options[:pagination] || false,
  407. per_page: options[:per_page],
  408. collapsible: options[:collapsible] || false,
  409. collapsed: options[:collapsed] || false
  410. )
  411. end
  412. end
  413. 1 ShowSectionDefinition = Struct.new(
  414. :name, :fields, :association, :limit, :render, :title,
  415. :display, :columns, :link_to, :resource, :paginate, :per_page, :collapsible, :collapsed,
  416. keyword_init: true
  417. )
  418. 1 class ActionsConfig
  419. 1 attr_reader :member_actions, :collection_actions, :bulk_actions
  420. 1 def initialize
  421. @member_actions = []
  422. @collection_actions = []
  423. @bulk_actions = []
  424. end
  425. 1 def action(name, **options)
  426. @member_actions << ActionDefinition.new(
  427. name: name,
  428. method: options[:method] || :post,
  429. confirm: options[:confirm],
  430. type: options[:type] || :button,
  431. label: options[:label] || name.to_s.humanize,
  432. icon: options[:icon],
  433. color: options[:color],
  434. if_condition: options[:if],
  435. unless_condition: options[:unless]
  436. )
  437. end
  438. 1 def collection_action(name, **options)
  439. @collection_actions << ActionDefinition.new(
  440. name: name,
  441. method: options[:method] || :post,
  442. confirm: options[:confirm],
  443. type: options[:type] || :button,
  444. label: options[:label] || name.to_s.humanize,
  445. icon: options[:icon],
  446. color: options[:color]
  447. )
  448. end
  449. 1 def bulk_action(name, **options)
  450. @bulk_actions << ActionDefinition.new(
  451. name: name,
  452. method: options[:method] || :post,
  453. confirm: options[:confirm],
  454. type: options[:type] || :button,
  455. label: options[:label] || name.to_s.humanize,
  456. icon: options[:icon],
  457. color: options[:color]
  458. )
  459. end
  460. end
  461. 1 ActionDefinition = Struct.new(
  462. :name, :method, :confirm, :type, :label, :icon, :color,
  463. :if_condition, :unless_condition,
  464. keyword_init: true
  465. )
  466. end
  467. end
  468. end

gems/admin_suite/lib/admin_suite.rb

95.83% lines covered

50.0% branches covered

24 relevant lines. 23 lines covered and 1 lines missed.
2 total branches, 1 branches covered and 1 branches missed.
    
  1. # frozen_string_literal: true
  2. begin
  3. 1 require "lucide-rails"
  4. rescue LoadError
  5. # Host app may choose a different icon provider via `AdminSuite.config.icon_renderer`.
  6. end
  7. 1 require "admin_suite/version"
  8. 1 require "admin_suite/configuration"
  9. 1 require "admin_suite/markdown_renderer"
  10. 1 require "admin_suite/theme_palette"
  11. 1 require "admin_suite/portal_registry"
  12. 1 require "admin_suite/portal_definition"
  13. 1 require "admin_suite/ui/form_field_renderer"
  14. 1 require "admin_suite/ui/show_value_formatter"
  15. 1 require "admin_suite/engine"
  16. 1 module AdminSuite
  17. 1 class << self
  18. # @return [AdminSuite::Configuration]
  19. 1 def config
  20. 511 @config ||= Configuration.new
  21. end
  22. # @yieldparam config [AdminSuite::Configuration]
  23. # @return [AdminSuite::Configuration]
  24. 1 def configure
  25. 2 yield(config)
  26. 2 config
  27. end
  28. # Defines (or updates) a portal using a Ruby DSL.
  29. #
  30. # Host apps typically place these in `app/admin/portals/*.rb`.
  31. #
  32. # @param key [Symbol, String]
  33. # @yield Portal definition DSL
  34. # @return [AdminSuite::PortalDefinition]
  35. 1 def portal(key, &block)
  36. 5 definition = PortalDefinition.new(key)
  37. 5 then: 5 else: 0 definition.instance_eval(&block) if block_given?
  38. 5 PortalRegistry.register(definition)
  39. 5 definition
  40. end
  41. # @return [Hash{Symbol=>AdminSuite::PortalDefinition}]
  42. 1 def portal_definitions
  43. PortalRegistry.all
  44. end
  45. end
  46. end

gems/admin_suite/lib/admin_suite/configuration.rb

100.0% lines covered

100.0% branches covered

20 relevant lines. 20 lines covered and 0 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. # Configuration object for AdminSuite.
  4. 1 class Configuration
  5. 1 attr_accessor :authenticate,
  6. :current_actor,
  7. :authorize,
  8. :resource_globs,
  9. :portal_globs,
  10. :portals,
  11. :custom_renderers,
  12. :icon_renderer,
  13. :docs_url,
  14. :docs_path,
  15. :partials,
  16. :theme,
  17. :host_stylesheet,
  18. :tailwind_cdn,
  19. :on_action_executed,
  20. :resolve_action_handler
  21. 1 def initialize
  22. 1 @authenticate = nil
  23. 1 @current_actor = nil
  24. 1 @authorize = nil
  25. 1 @resource_globs = []
  26. 1 @portal_globs = []
  27. 1 @portals = {}
  28. 1 @custom_renderers = {}
  29. 1 @icon_renderer = nil
  30. 1 @docs_url = nil
  31. 1 @docs_path = Rails.root.join("docs")
  32. 1 @partials = {}
  33. 1 @theme = { primary: :indigo, secondary: :purple }
  34. 1 @host_stylesheet = nil
  35. 1 @tailwind_cdn = true
  36. 1 @on_action_executed = nil
  37. 1 @resolve_action_handler = nil
  38. end
  39. end
  40. end

gems/admin_suite/lib/admin_suite/engine.rb

70.37% lines covered

42.86% branches covered

27 relevant lines. 19 lines covered and 8 lines missed.
14 total branches, 6 branches covered and 8 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "fileutils"
  3. 1 module AdminSuite
  4. 1 class Engine < ::Rails::Engine
  5. 1 isolate_namespace AdminSuite
  6. 1 initializer "admin_suite.watchable_dirs" do |app|
  7. 1 else: 0 then: 1 next unless Rails.env.development?
  8. # Make local-engine edits reload without a full server restart.
  9. app.config.watchable_dirs[root.join("app").to_s] = %w[rb erb js css]
  10. app.config.watchable_dirs[root.join("lib").to_s] = %w[rb]
  11. app.config.watchable_dirs[root.join("config").to_s] = %w[rb]
  12. end
  13. 1 initializer "admin_suite.assets", before: "propshaft" do |app|
  14. # Make engine JS/CSS available to the host asset pipeline (Propshaft/Sprockets).
  15. 1 app.config.assets.paths << root.join("app/javascript")
  16. 1 app.config.assets.paths << root.join("app/assets")
  17. end
  18. 1 initializer "admin_suite.importmap", before: "importmap" do |app|
  19. # Make engine-provided JS available to host apps using importmap-rails.
  20. 1 then: 1 else: 0 if app.config.respond_to?(:importmap) && app.config.importmap.respond_to?(:paths)
  21. 1 app.config.importmap.paths << root.join("config/importmap.rb")
  22. end
  23. end
  24. 1 initializer "admin_suite.configuration" do
  25. # Provide sensible defaults for host apps.
  26. 1 AdminSuite.configure do |config|
  27. 1 then: 0 else: 1 config.resource_globs = [ Rails.root.join("app/admin/resources/*.rb").to_s ] if config.resource_globs.blank?
  28. 1 then: 0 else: 1 config.portal_globs = [ Rails.root.join("app/admin/portals/*.rb").to_s ] if config.portal_globs.blank?
  29. then: 0 else: 1 config.portals = {
  30. ops: { label: "Ops Portal", icon: "settings", color: :amber, order: 10 },
  31. email: { label: "Email Portal", icon: "inbox", color: :emerald, order: 20 },
  32. ai: { label: "AI Portal", icon: "cpu", color: :cyan, order: 30 },
  33. assistant: { label: "Assistant Portal", icon: "message-circle", color: :violet, order: 40 }
  34. 1 } if config.portals.blank?
  35. end
  36. end
  37. 1 initializer "admin_suite.tailwind_build" do
  38. 1 else: 0 then: 1 next unless Rails.env.development?
  39. # In development, ensure the engine stylesheet exists so the UI is usable
  40. # without requiring host-specific Tailwind setup.
  41. output = Rails.root.join("app/assets/builds/admin_suite_tailwind.css")
  42. then: 0 else: 0 next if output.exist?
  43. input = root.join("app/assets/tailwind/admin_suite.css")
  44. FileUtils.mkdir_p(output.dirname)
  45. system("tailwindcss", "-i", input.to_s, "-o", output.to_s)
  46. rescue StandardError
  47. # Best effort only; missing stylesheet will show up immediately in the UI.
  48. end
  49. end
  50. end

gems/admin_suite/lib/admin_suite/markdown_renderer.rb

92.0% lines covered

75.0% branches covered

50 relevant lines. 46 lines covered and 4 lines missed.
8 total branches, 6 branches covered and 2 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "redcarpet"
  3. 1 require "rouge"
  4. 1 require "rouge/plugins/redcarpet"
  5. 1 module AdminSuite
  6. # MarkdownRenderer converts markdown text into safe HTML with syntax highlighting.
  7. #
  8. # Uses Redcarpet for markdown parsing, Rouge for syntax highlighting,
  9. # and extracts a table of contents from headings.
  10. 1 class MarkdownRenderer
  11. 1 attr_reader :markdown
  12. 1 def initialize(markdown)
  13. 4 @markdown = markdown.to_s
  14. end
  15. # @return [Hash{Symbol=>Object}]
  16. 1 def render
  17. 4 result = self.class.render_with_toc(markdown)
  18. {
  19. 4 html: result[:html],
  20. toc: result[:toc],
  21. reading_time_minutes: reading_time_minutes
  22. }
  23. end
  24. # @param text [String]
  25. # @return [ActiveSupport::SafeBuffer]
  26. 1 def self.render(text)
  27. renderer = HtmlRenderer.new
  28. md = Redcarpet::Markdown.new(renderer, markdown_extensions)
  29. md.render(text.to_s).html_safe
  30. end
  31. # @param text [String]
  32. # @return [Hash{Symbol=>Object}]
  33. 1 def self.render_with_toc(text)
  34. 4 renderer = HtmlRenderer.new
  35. 4 md = Redcarpet::Markdown.new(renderer, markdown_extensions)
  36. 4 html = md.render(text.to_s).html_safe
  37. 4 { html: html, toc: renderer.toc_items }
  38. end
  39. 1 private
  40. 1 def self.markdown_extensions
  41. 4 {
  42. autolink: true,
  43. tables: true,
  44. fenced_code_blocks: true,
  45. strikethrough: true,
  46. highlight: true,
  47. superscript: true,
  48. underline: true,
  49. no_intra_emphasis: true,
  50. space_after_headers: true,
  51. lax_spacing: true
  52. }
  53. end
  54. # Rough reading time estimate assuming 200 wpm.
  55. # @return [Integer]
  56. 1 def reading_time_minutes
  57. 4 words = markdown.scan(/\b[\p{L}\p{N}']+\b/).size
  58. 4 [ (words / 200.0).ceil, 1 ].max
  59. end
  60. 1 class HtmlRenderer < Redcarpet::Render::HTML
  61. 1 include Rouge::Plugins::Redcarpet
  62. 1 attr_reader :toc_items
  63. 1 def initialize(extensions = {})
  64. 4 super(extensions.merge(
  65. hard_wrap: true,
  66. link_attributes: { target: "_blank", rel: "noopener noreferrer" },
  67. with_toc_data: true
  68. ))
  69. 4 @toc_items = []
  70. 4 @heading_ids = Hash.new(0)
  71. end
  72. 1 def block_code(code, language)
  73. 8 language ||= "text"
  74. 8 lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new
  75. 8 formatter = Rouge::Formatters::HTML.new
  76. 8 highlighted = formatter.format(lexer.lex(code))
  77. 8 then: 7 else: 1 lang_label = language != "text" ? %(<span class="code-lang">#{language}</span>) : ""
  78. 8 %(<div class="code-block">#{lang_label}<pre class="highlight #{language}"><code>#{highlighted}</code></pre></div>)
  79. end
  80. 1 def header(text, header_level)
  81. 54 base_slug = text.to_s.downcase.strip.gsub(/\s+/, "-").gsub(/[^\w-]/, "")
  82. 54 then: 0 else: 54 base_slug = "section" if base_slug.blank?
  83. 54 @heading_ids[base_slug] += 1
  84. 54 then: 0 else: 54 slug = @heading_ids[base_slug] > 1 ? "#{base_slug}-#{@heading_ids[base_slug]}" : base_slug
  85. 54 then: 51 else: 3 if header_level >= 2 && header_level <= 4
  86. 51 @toc_items << { level: header_level, id: slug, text: text }
  87. end
  88. 54 %(<h#{header_level} id="#{slug}">#{text}</h#{header_level}>\n)
  89. end
  90. 1 def table(header, body)
  91. %(<table class="admin-suite-doc-table"><thead>#{header}</thead><tbody>#{body}</tbody></table>\n)
  92. end
  93. end
  94. end
  95. end

gems/admin_suite/lib/admin_suite/portal_definition.rb

97.14% lines covered

50.0% branches covered

35 relevant lines. 34 lines covered and 1 lines missed.
12 total branches, 6 branches covered and 6 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "admin_suite/ui/dashboard_definition"
  3. 1 module AdminSuite
  4. 1 class PortalDefinition
  5. 1 attr_reader :key
  6. 1 def initialize(key)
  7. 5 @key = key.to_sym
  8. 5 @label = nil
  9. 5 @icon = nil
  10. 5 @color = nil
  11. 5 @order = nil
  12. 5 @description = nil
  13. 5 @dashboard = nil
  14. end
  15. 1 def label(value = nil)
  16. 5 then: 5 else: 0 @label = value if value.present?
  17. 5 @label
  18. end
  19. 1 def icon(value = nil)
  20. 5 then: 5 else: 0 @icon = value if value.present?
  21. 5 @icon
  22. end
  23. 1 def color(value = nil)
  24. 5 then: 5 else: 0 @color = value if value.present?
  25. 5 @color
  26. end
  27. 1 def order(value = nil)
  28. 5 else: 0 then: 5 @order = value unless value.nil?
  29. 5 @order
  30. end
  31. 1 def description(value = nil)
  32. 5 then: 5 else: 0 @description = value if value.present?
  33. 5 @description
  34. end
  35. 1 def dashboard(&block)
  36. 5 @dashboard ||= UI::DashboardDefinition.new
  37. 5 then: 5 else: 0 UI::DashboardDSL.new(@dashboard).instance_eval(&block) if block_given?
  38. 5 @dashboard
  39. end
  40. 1 def dashboard_definition
  41. @dashboard
  42. end
  43. 1 def to_nav_meta
  44. {
  45. 675 label: @label,
  46. icon: @icon,
  47. color: @color,
  48. order: @order,
  49. description: @description
  50. }.compact
  51. end
  52. end
  53. end

gems/admin_suite/lib/admin_suite/portal_registry.rb

81.82% lines covered

100.0% branches covered

11 relevant lines. 9 lines covered and 2 lines missed.
0 total branches, 0 branches covered and 0 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. # Stores portal definitions registered via `AdminSuite.portal`.
  4. 1 module PortalRegistry
  5. 1 class << self
  6. # @return [Hash{Symbol=>AdminSuite::PortalDefinition}]
  7. 1 def all
  8. 275 @all ||= {}
  9. end
  10. # @param definition [AdminSuite::PortalDefinition]
  11. # @return [AdminSuite::PortalDefinition]
  12. 1 def register(definition)
  13. 5 all[definition.key] = definition
  14. end
  15. # @param key [Symbol, String]
  16. # @return [AdminSuite::PortalDefinition, nil]
  17. 1 def fetch(key)
  18. all[key.to_sym]
  19. end
  20. # Clears the registry (useful for development reloads).
  21. #
  22. # @return [void]
  23. 1 def reset!
  24. @all = {}
  25. end
  26. end
  27. end
  28. end

gems/admin_suite/lib/admin_suite/theme_palette.rb

100.0% lines covered

50.0% branches covered

12 relevant lines. 12 lines covered and 0 lines missed.
4 total branches, 2 branches covered and 2 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 module ThemePalette
  4. # Minimal Tailwind-like palette values (hex) for theming.
  5. # We only include the shades AdminSuite uses.
  6. COLORS = {
  7. 1 "slate" => { 100 => "#f1f5f9", 200 => "#e2e8f0", 500 => "#64748b", 600 => "#475569", 700 => "#334155", 800 => "#1e293b", 900 => "#0f172a" },
  8. "indigo" => { 100 => "#e0e7ff", 200 => "#c7d2fe", 500 => "#6366f1", 600 => "#4f46e5", 700 => "#4338ca", 800 => "#3730a3", 900 => "#312e81" },
  9. "purple" => { 100 => "#f3e8ff", 200 => "#e9d5ff", 500 => "#a855f7", 600 => "#9333ea", 700 => "#7e22ce", 800 => "#6b21a8", 900 => "#581c87" },
  10. "violet" => { 100 => "#ede9fe", 200 => "#ddd6fe", 500 => "#8b5cf6", 600 => "#7c3aed", 700 => "#6d28d9", 800 => "#5b21b6", 900 => "#4c1d95" },
  11. "amber" => { 100 => "#fef3c7", 200 => "#fde68a", 500 => "#f59e0b", 600 => "#d97706", 700 => "#b45309", 800 => "#92400e", 900 => "#78350f" },
  12. "emerald" => { 100 => "#d1fae5", 200 => "#a7f3d0", 500 => "#10b981", 600 => "#059669", 700 => "#047857", 800 => "#065f46", 900 => "#064e3b" },
  13. "cyan" => { 100 => "#cffafe", 200 => "#a5f3fc", 500 => "#06b6d4", 600 => "#0891b2", 700 => "#0e7490", 800 => "#155e75", 900 => "#164e63" },
  14. "blue" => { 100 => "#dbeafe", 200 => "#bfdbfe", 500 => "#3b82f6", 600 => "#2563eb", 700 => "#1d4ed8", 800 => "#1e40af", 900 => "#1e3a8a" },
  15. "green" => { 100 => "#dcfce7", 200 => "#bbf7d0", 500 => "#22c55e", 600 => "#16a34a", 700 => "#15803d", 800 => "#166534", 900 => "#14532d" },
  16. "red" => { 100 => "#fee2e2", 200 => "#fecaca", 500 => "#ef4444", 600 => "#dc2626", 700 => "#b91c1c", 800 => "#991b1b", 900 => "#7f1d1d" }
  17. }.freeze
  18. 1 def self.resolve(color_name, shade, fallback: nil)
  19. 34 then: 0 else: 34 return fallback if color_name.blank?
  20. 34 name = color_name.to_s.delete_prefix(":")
  21. 34 COLORS.dig(name, shade) || fallback
  22. end
  23. 1 def self.normalize_color(value, default_name:)
  24. 12 then: 0 else: 12 return default_name.to_s if value.blank?
  25. 12 value.to_s.delete_prefix(":")
  26. end
  27. 1 def self.hex?(value)
  28. 34 value.is_a?(String) && value.match?(/\A#(?:[0-9a-fA-F]{3}){1,2}\z/)
  29. end
  30. end
  31. end

gems/admin_suite/lib/admin_suite/ui/dashboard_definition.rb

97.3% lines covered

35.71% branches covered

37 relevant lines. 36 lines covered and 1 lines missed.
14 total branches, 5 branches covered and 9 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 module UI
  4. 1 PanelDefinition = Struct.new(:type, :title, :options, keyword_init: true)
  5. 1 RowDefinition = Struct.new(:panels, keyword_init: true)
  6. 1 class DashboardDefinition
  7. 1 attr_reader :rows
  8. 1 def initialize
  9. 5 @rows = []
  10. end
  11. end
  12. # DSL used inside `portal.dashboard do ... end`.
  13. 1 class DashboardDSL
  14. 1 def initialize(definition)
  15. 5 @definition = definition
  16. end
  17. 1 def row(&block)
  18. 18 row = RowDefinition.new(panels: [])
  19. 18 then: 18 else: 0 RowDSL.new(row).instance_eval(&block) if block_given?
  20. 18 @definition.rows << row
  21. 18 row
  22. end
  23. end
  24. # DSL used inside `row do ... end`.
  25. 1 class RowDSL
  26. 1 def initialize(row)
  27. 18 @row = row
  28. end
  29. 1 def panel(type, title = nil, span: nil, **options, &block)
  30. 57 then: 57 else: 0 options[:span] = span if span
  31. 57 then: 0 else: 57 options[:block] = block if block_given?
  32. 57 @row.panels << PanelDefinition.new(type: type.to_sym, title: title, options: options)
  33. end
  34. 1 def stat_panel(title, value = nil, span: nil, **options, &block)
  35. 35 then: 35 else: 0 then: 0 else: 0 value_proc = value.is_a?(Proc) ? value : (block_given? ? block : nil)
  36. 35 panel(:stat, title, span: span, **options.merge(value: value_proc || value))
  37. end
  38. 1 def health_panel(title, status: nil, metrics: nil, span: nil, **options, &block)
  39. 4 panel(:health, title, span: span, **options.merge(status: status, metrics: metrics, block: block))
  40. end
  41. 1 def chart_panel(title, data: nil, span: nil, **options, &block)
  42. 6 then: 6 else: 0 then: 0 else: 0 data_proc = data.is_a?(Proc) ? data : (block_given? ? block : nil)
  43. 6 panel(:chart, title, span: span, **options.merge(data: data_proc || data))
  44. end
  45. 1 def cards_panel(title, resources: nil, span: nil, **options, &block)
  46. 4 panel(:cards, title, span: span, **options.merge(resources: resources, block: block))
  47. end
  48. 1 def recent_panel(title, scope: nil, link: nil, span: nil, **options, &block)
  49. 8 panel(:recent, title, span: span, **options.merge(scope: scope, link: link, block: block))
  50. end
  51. 1 def table_panel(title, rows: nil, columns: nil, span: nil, **options, &block)
  52. panel(:table, title, span: span, **options.merge(rows: rows, columns: columns, block: block))
  53. end
  54. end
  55. end
  56. end

gems/admin_suite/lib/admin_suite/ui/field_renderer_registry.rb

53.33% lines covered

0.0% branches covered

60 relevant lines. 32 lines covered and 28 lines missed.
4 total branches, 0 branches covered and 4 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 module UI
  4. 1 module FieldRendererRegistry
  5. 1 class << self
  6. 1 def handlers
  7. 23 @handlers ||= {}
  8. end
  9. 1 def register(type, &block)
  10. 23 handlers[type.to_sym] = block
  11. end
  12. 1 def render(type, view:, f:, field:, resource:, field_class:)
  13. handler = handlers[type.to_sym]
  14. else: 0 then: 0 return nil unless handler
  15. handler.call(view, f, field, resource, field_class)
  16. end
  17. end
  18. end
  19. end
  20. end
  21. # ---- default field renderers ----
  22. 1 AdminSuite::UI::FieldRendererRegistry.register(:textarea) do |_view, f, field, resource, field_class|
  23. f.text_area(field.name, class: field_class, rows: field.rows || 4, placeholder: field.placeholder, readonly: field.readonly)
  24. end
  25. 1 AdminSuite::UI::FieldRendererRegistry.register(:url) do |_view, f, field, resource, field_class|
  26. f.url_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  27. end
  28. 1 AdminSuite::UI::FieldRendererRegistry.register(:email) do |_view, f, field, resource, field_class|
  29. f.email_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  30. end
  31. 1 AdminSuite::UI::FieldRendererRegistry.register(:number) do |_view, f, field, resource, field_class|
  32. f.number_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  33. end
  34. 1 AdminSuite::UI::FieldRendererRegistry.register(:toggle) do |view, f, field, resource, _field_class|
  35. view.render_toggle_field(f, field, resource)
  36. end
  37. 1 AdminSuite::UI::FieldRendererRegistry.register(:label) do |view, _f, field, resource, _field_class|
  38. label_value = resource.public_send(field.name) rescue nil
  39. view.render_label_badge(label_value, color: field.label_color, size: field.label_size, record: resource)
  40. end
  41. 1 AdminSuite::UI::FieldRendererRegistry.register(:select) do |_view, f, field, resource, field_class|
  42. then: 0 else: 0 collection = field.collection.is_a?(Proc) ? field.collection.call : field.collection
  43. f.select(field.name, collection, { include_blank: true }, class: field_class, disabled: field.readonly)
  44. end
  45. 1 AdminSuite::UI::FieldRendererRegistry.register(:searchable_select) do |view, f, field, resource, _field_class|
  46. view.render_searchable_select(f, field, resource)
  47. end
  48. 1 AdminSuite::UI::FieldRendererRegistry.register(:multi_select) do |view, f, field, resource, _field_class|
  49. view.render_multi_select(f, field, resource)
  50. end
  51. 1 AdminSuite::UI::FieldRendererRegistry.register(:tags) do |view, f, field, resource, _field_class|
  52. view.render_multi_select(f, field, resource)
  53. end
  54. 1 AdminSuite::UI::FieldRendererRegistry.register(:image) do |view, f, field, resource, _field_class|
  55. view.render_file_upload(f, field, resource)
  56. end
  57. 1 AdminSuite::UI::FieldRendererRegistry.register(:attachment) do |view, f, field, resource, _field_class|
  58. view.render_file_upload(f, field, resource)
  59. end
  60. 1 AdminSuite::UI::FieldRendererRegistry.register(:trix) do |_view, f, field, resource, _field_class|
  61. f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
  62. end
  63. 1 AdminSuite::UI::FieldRendererRegistry.register(:rich_text) do |_view, f, field, resource, _field_class|
  64. f.rich_text_area(field.name, class: "prose dark:prose-invert max-w-none")
  65. end
  66. 1 AdminSuite::UI::FieldRendererRegistry.register(:markdown) do |_view, f, field, resource, field_class|
  67. f.text_area(field.name, class: "#{field_class} font-mono", rows: field.rows || 12, data: { controller: "admin-suite--markdown-editor" }, placeholder: field.placeholder)
  68. end
  69. 1 AdminSuite::UI::FieldRendererRegistry.register(:file) do |_view, f, field, resource, _field_class|
  70. f.file_field(field.name, class: "form-input-file", accept: field.accept)
  71. end
  72. 1 AdminSuite::UI::FieldRendererRegistry.register(:datetime) do |_view, f, field, resource, field_class|
  73. f.datetime_local_field(field.name, class: field_class, readonly: field.readonly)
  74. end
  75. 1 AdminSuite::UI::FieldRendererRegistry.register(:date) do |_view, f, field, resource, field_class|
  76. f.date_field(field.name, class: field_class, readonly: field.readonly)
  77. end
  78. 1 AdminSuite::UI::FieldRendererRegistry.register(:time) do |_view, f, field, resource, field_class|
  79. f.time_field(field.name, class: field_class, readonly: field.readonly)
  80. end
  81. 1 AdminSuite::UI::FieldRendererRegistry.register(:json) do |view, f, field, resource, _field_class|
  82. view.render("admin_suite/shared/json_editor_field", f: f, field: field, resource: resource)
  83. end
  84. 1 AdminSuite::UI::FieldRendererRegistry.register(:code) do |view, f, field, resource, _field_class|
  85. view.render_code_editor(f, field, resource)
  86. end
  87. 1 AdminSuite::UI::FieldRendererRegistry.register(:text) do |_view, f, field, resource, field_class|
  88. f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  89. end
  90. 1 AdminSuite::UI::FieldRendererRegistry.register(:string) do |_view, f, field, resource, field_class|
  91. f.text_field(field.name, class: field_class, placeholder: field.placeholder, readonly: field.readonly)
  92. end

gems/admin_suite/lib/admin_suite/ui/form_field_renderer.rb

25.0% lines covered

0.0% branches covered

20 relevant lines. 5 lines covered and 15 lines missed.
16 total branches, 0 branches covered and 16 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "admin_suite/ui/field_renderer_registry"
  3. 1 module AdminSuite
  4. 1 module UI
  5. # Overrides `render_form_field` to use a registry of field renderers,
  6. # while leaving the legacy implementation available via `super`.
  7. 1 module FormFieldRenderer
  8. 1 def render_form_field(f, field, resource)
  9. else: 0 then: 0 return super unless defined?(AdminSuite::UI::FieldRendererRegistry)
  10. then: 0 else: 0 return if field.if_condition.present? && !field.if_condition.call(resource)
  11. then: 0 else: 0 return if field.unless_condition.present? && field.unless_condition.call(resource)
  12. capture do
  13. concat(content_tag(:div, class: "form-group") do
  14. concat(f.label(field.name, class: "form-label") do
  15. concat(field.label)
  16. then: 0 else: 0 concat(content_tag(:span, " *", class: "text-red-500")) if field.required
  17. end)
  18. field_class = "form-input w-full"
  19. then: 0 else: 0 field_class += " border-red-500" if resource.errors[field.name].any?
  20. field_html =
  21. AdminSuite::UI::FieldRendererRegistry.render(
  22. field.type || :text,
  23. view: self,
  24. f: f,
  25. field: field,
  26. resource: resource,
  27. field_class: field_class
  28. )
  29. # If the registry doesn't know how to render, fall back to legacy behavior.
  30. then: 0 else: 0 return super if field_html.nil?
  31. concat(field_html)
  32. then: 0 else: 0 concat(content_tag(:p, field.help, class: "mt-1 text-sm text-slate-500 dark:text-slate-400")) if field.help.present?
  33. then: 0 else: 0 concat(content_tag(:p, resource.errors[field.name].first, class: "mt-1 text-sm text-red-600 dark:text-red-400")) if resource.errors[field.name].any?
  34. end)
  35. end
  36. end
  37. end
  38. end
  39. end

gems/admin_suite/lib/admin_suite/ui/show_formatter_registry.rb

38.1% lines covered

0.0% branches covered

63 relevant lines. 24 lines covered and 39 lines missed.
14 total branches, 0 branches covered and 14 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 module AdminSuite
  3. 1 module UI
  4. 1 module ShowFormatterRegistry
  5. 1 class << self
  6. 1 def class_handlers
  7. 11 @class_handlers ||= {}
  8. end
  9. 1 def default_handler
  10. @default_handler
  11. end
  12. 1 def register_class(klass, &block)
  13. 11 class_handlers[klass] = block
  14. end
  15. 1 def register_default(&block)
  16. 1 @default_handler = block
  17. end
  18. 1 def format(value, view:, record:, field_name:)
  19. then: 0 else: 0 handler = class_handlers.find { |klass, _| value.is_a?(klass) }&.last
  20. handler ||= default_handler
  21. else: 0 then: 0 return nil unless handler
  22. handler.call(value, view, record, field_name)
  23. end
  24. end
  25. end
  26. end
  27. end
  28. # ---- default show formatters ----
  29. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(NilClass) do |_value, view, _record, _field|
  30. view.content_tag(:span, "—", class: "text-slate-400")
  31. end
  32. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(TrueClass) do |_value, view, _record, _field|
  33. view.content_tag(:span, class: "inline-flex items-center gap-1") do
  34. view.concat(view.admin_suite_icon("check-circle-2", class: "w-4 h-4 text-green-500"))
  35. view.concat(view.content_tag(:span, "Yes", class: "text-green-600 dark:text-green-400 font-medium"))
  36. end
  37. end
  38. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(FalseClass) do |_value, view, _record, _field|
  39. view.content_tag(:span, class: "inline-flex items-center gap-1") do
  40. view.concat(view.admin_suite_icon("x-circle", class: "w-4 h-4 text-slate-400"))
  41. view.concat(view.content_tag(:span, "No", class: "text-slate-500"))
  42. end
  43. end
  44. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(Time) do |value, view, _record, _field|
  45. view.content_tag(:span, class: "inline-flex items-center gap-2") do
  46. view.concat(view.content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
  47. view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
  48. end
  49. end
  50. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(DateTime) do |value, view, _record, _field|
  51. view.content_tag(:span, class: "inline-flex items-center gap-2") do
  52. view.concat(view.content_tag(:span, value.strftime("%B %d, %Y at %H:%M"), class: "font-medium"))
  53. view.concat(view.content_tag(:span, "(#{view.time_ago_in_words(value)} ago)", class: "text-slate-500 dark:text-slate-400 text-xs"))
  54. end
  55. end
  56. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(Date) do |value, _view, _record, _field|
  57. value.strftime("%B %d, %Y")
  58. end
  59. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(ActiveRecord::Base) do |value, view, _record, _field|
  60. then: 0 else: 0 link_text = value.respond_to?(:name) ? value.name : "#{value.class.name} ##{value.id}"
  61. view.content_tag(:span, link_text, class: "text-indigo-600 dark:text-indigo-400")
  62. end
  63. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(Hash) do |value, view, _record, _field|
  64. view.render_json_block(value)
  65. end
  66. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(Array) do |value, view, _record, _field|
  67. then: 0 if value.empty?
  68. else: 0 view.content_tag(:span, "Empty array", class: "text-slate-400 italic")
  69. then: 0 elsif value.first.is_a?(Hash)
  70. view.render_json_block(value)
  71. else: 0 else
  72. view.content_tag(:div, class: "flex flex-wrap gap-1") do
  73. value.each do |item|
  74. view.concat(view.content_tag(:span, item.to_s, class: "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300"))
  75. end
  76. end
  77. end
  78. end
  79. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(Integer) do |value, view, _record, _field|
  80. view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
  81. end
  82. 1 AdminSuite::UI::ShowFormatterRegistry.register_class(Float) do |value, view, _record, _field|
  83. view.content_tag(:span, view.number_with_delimiter(value), class: "font-mono")
  84. end
  85. 1 AdminSuite::UI::ShowFormatterRegistry.register_default do |value, view, _record, field_name|
  86. value_str = value.to_s
  87. then: 0 if value_str.start_with?("{", "[") && value_str.length > 10
  88. begin
  89. parsed = JSON.parse(value_str)
  90. view.render_json_block(parsed)
  91. rescue JSON::ParserError
  92. view.render_text_block(value_str)
  93. else: 0 end
  94. then: 0 elsif value_str.include?("\n") || value_str.length > 200
  95. view.render_text_block(value_str, view.detect_language(field_name, value_str))
  96. else: 0 else
  97. value_str
  98. end
  99. end

gems/admin_suite/lib/admin_suite/ui/show_value_formatter.rb

16.67% lines covered

0.0% branches covered

30 relevant lines. 5 lines covered and 25 lines missed.
30 total branches, 0 branches covered and 30 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "admin_suite/ui/show_formatter_registry"
  3. 1 module AdminSuite
  4. 1 module UI
  5. # Overrides `format_show_value` to use a registry of show value formatters,
  6. # while leaving the legacy implementation available via `super`.
  7. 1 module ShowValueFormatter
  8. 1 def format_show_value(record, field_name)
  9. value = record.public_send(field_name) rescue nil
  10. then: 0 else: 0 if (field_def = admin_suite_field_definition(field_name))
  11. else: 0 case field_def.type
  12. when: 0 when :markdown
  13. rendered =
  14. then: 0 if defined?(::MarkdownRenderer)
  15. ::MarkdownRenderer.render(value.to_s)
  16. else: 0 else
  17. simple_format(value.to_s)
  18. end
  19. return content_tag(:div, rendered, class: "prose dark:prose-invert max-w-none")
  20. when: 0 when :json
  21. begin
  22. parsed =
  23. then: 0 if value.is_a?(Hash) || value.is_a?(Array)
  24. else: 0 value
  25. then: 0 else: 0 elsif value.present?
  26. JSON.parse(value.to_s)
  27. end
  28. then: 0 else: 0 return render_json_block(parsed) if parsed
  29. rescue JSON::ParserError
  30. # fall through
  31. end
  32. when: 0 when :label
  33. return render_label_badge(value, color: field_def.label_color, size: field_def.label_size, record: record)
  34. end
  35. end
  36. # If the field isn't in the form config, fall back to index column config
  37. # so show pages can still render labels consistently.
  38. then: 0 else: 0 then: 0 else: 0 if respond_to?(:resource_config, true) && (rc = resource_config) && rc.index_config&.columns_list
  39. col = rc.index_config.columns_list.find { |c| c.name.to_sym == field_name.to_sym }
  40. then: 0 else: 0 then: 0 else: 0 if col&.type == :label
  41. then: 0 else: 0 label_value = col.content.is_a?(Proc) ? col.content.call(record) : value
  42. return render_label_badge(label_value, color: col.label_color, size: col.label_size, record: record)
  43. end
  44. end
  45. then: 0 if value.is_a?(ActiveStorage::Attached::One)
  46. else: 0 return render_attachment_preview(value)
  47. then: 0 else: 0 elsif value.is_a?(ActiveStorage::Attached::Many)
  48. return render_attachments_preview(value)
  49. end
  50. formatted =
  51. AdminSuite::UI::ShowFormatterRegistry.format(
  52. value,
  53. view: self,
  54. record: record,
  55. field_name: field_name
  56. )
  57. else: 0 then: 0 return formatted unless formatted.nil?
  58. super
  59. end
  60. end
  61. end
  62. end

lib/omniauth/strategies/techwright.rb

45.45% lines covered

0.0% branches covered

33 relevant lines. 15 lines covered and 18 lines missed.
2 total branches, 0 branches covered and 2 branches missed.
    
  1. # frozen_string_literal: true
  2. 1 require "omniauth-oauth2"
  3. 1 module OmniAuth
  4. 1 module Strategies
  5. # TechWright OAuth2 strategy for OmniAuth
  6. #
  7. # Used for authenticating developers to the internal admin portal.
  8. # This is separate from regular user authentication.
  9. #
  10. # Supports separate URLs for browser (authorize) and server (token) requests,
  11. # which is needed for devcontainer setups where localhost from the container
  12. # doesn't reach the host machine.
  13. #
  14. # @example Configuration
  15. # provider :techwright,
  16. # Rails.application.credentials.dig(:techwright, :client_id),
  17. # Rails.application.credentials.dig(:techwright, :client_secret),
  18. # scope: "openid email profile",
  19. # client_options: {
  20. # site: "http://localhost:3003",
  21. # token_site: "http://host.docker.internal:3003"
  22. # }
  23. #
  24. 1 class Techwright < OmniAuth::Strategies::OAuth2
  25. 1 option :name, "techwright"
  26. # Default to production URL, can be overridden via credentials or provider options
  27. 1 option :client_options, {
  28. site: "https://techwright.io",
  29. authorize_url: "/oauth/authorize",
  30. token_url: "/oauth/token"
  31. }
  32. # Optional separate site for server-side requests (token exchange, userinfo)
  33. # Used in devcontainer setups where localhost doesn't work from inside container
  34. 1 option :token_site, nil
  35. # Returns the unique identifier for the user
  36. #
  37. # @return [String] The user's TechWright ID
  38. 1 uid { raw_info["sub"] }
  39. # Returns user information from the OAuth response
  40. #
  41. # @return [Hash] User info including email, name, picture, and verification status
  42. 1 info do
  43. {
  44. email: raw_info["email"],
  45. name: raw_info["name"],
  46. image: raw_info["picture"],
  47. email_verified: raw_info["email_verified"]
  48. }
  49. end
  50. # Returns additional data from the OAuth response
  51. #
  52. # @return [Hash] Extra data including the raw userinfo response
  53. 1 extra do
  54. { raw_info: raw_info }
  55. end
  56. # Override client to use token_site for server-side requests
  57. #
  58. # @return [OAuth2::Client]
  59. 1 def client
  60. @client ||= begin
  61. # Use token_site if provided, otherwise fall back to site
  62. server_site = options.token_site || options.client_options[:token_site] || options.client_options[:site]
  63. ::OAuth2::Client.new(
  64. options.client_id,
  65. options.client_secret,
  66. deep_symbolize(options.client_options.merge(site: server_site))
  67. )
  68. end
  69. end
  70. # Fetches user information from the TechWright userinfo endpoint
  71. #
  72. # @return [Hash] The parsed userinfo response
  73. 1 def raw_info
  74. @raw_info ||= access_token.get("/oauth/userinfo").parsed
  75. end
  76. # Returns the callback URL for OAuth redirects
  77. #
  78. # @return [String] The full callback URL
  79. 1 def callback_url
  80. full_host + callback_path
  81. end
  82. # Build the authorize URL using the browser-accessible site
  83. #
  84. # @param params [Hash] Additional parameters
  85. # @return [String] The authorization URL
  86. 1 def authorize_url(params = {})
  87. # Use the original site (browser-accessible) for authorize URL
  88. browser_site = options.client_options[:site]
  89. authorize_path = options.client_options[:authorize_url] || "/oauth/authorize"
  90. uri = URI.parse(browser_site)
  91. uri.path = authorize_path
  92. then: 0 else: 0 uri.query = URI.encode_www_form(params) if params.any?
  93. uri.to_s
  94. end
  95. # Override request_phase to use browser-accessible site for redirect
  96. 1 def request_phase
  97. browser_site = options.client_options[:site]
  98. authorize_path = options.client_options[:authorize_url] || "/oauth/authorize"
  99. # Build authorize params
  100. authorize_params = {
  101. client_id: options.client_id,
  102. redirect_uri: callback_url,
  103. response_type: "code",
  104. scope: options.scope
  105. }
  106. authorize_params[:state] = session["omniauth.state"] = SecureRandom.hex(24)
  107. redirect URI.parse(browser_site).merge(authorize_path + "?" + URI.encode_www_form(authorize_params)).to_s
  108. end
  109. end
  110. end
  111. end